diff --git a/addons/crm/__manifest__.py b/addons/crm/__manifest__.py index d74210125f3..7a4e97964cf 100644 --- a/addons/crm/__manifest__.py +++ b/addons/crm/__manifest__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + { 'name': 'CRM', 'version': '1.4', @@ -18,6 +19,7 @@ 'fetchmail', 'utm', 'web_tour', + 'web_kanban_gauge', 'contacts', 'digest', 'phone_validation', @@ -31,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', @@ -46,6 +49,7 @@ 'views/crm_lost_reason_views.xml', 'views/crm_stage_views.xml', 'views/crm_lead_views.xml', + 'views/crm_team_member_views.xml', 'views/digest_views.xml', 'views/mail_activity_views.xml', 'views/res_config_settings_views.xml', @@ -58,6 +62,7 @@ 'demo': [ 'data/crm_team_demo.xml', 'data/mail_template_demo.xml', + 'data/crm_team_member_demo.xml', 'data/mail_activity_demo.xml', 'data/crm_lead_demo.xml', ], diff --git a/addons/crm/data/crm_lead_demo.xml b/addons/crm/data/crm_lead_demo.xml index 144ed8e5789..b831a9dda15 100644 --- a/addons/crm/data/crm_lead_demo.xml +++ b/addons/crm/data/crm_lead_demo.xml @@ -86,7 +86,7 @@ +1 555 754 3010 2 - + @@ -108,7 +108,7 @@ United Street 68 2 - + @@ -132,7 +132,7 @@ +1 813 494 5005 2 - + @@ -171,7 +171,7 @@ Contact: +1 813 494 5005

]]>
00118 2 - + @@ -249,7 +249,7 @@ ESM Expert
]]>
OX1 1RQ 2 - + @@ -305,7 +305,7 @@ Andrew

]]>
1300 2 - + @@ -604,7 +604,7 @@ Andrew

]]>
1 - + @@ -667,7 +667,7 @@ Andrew

]]>
1 - + @@ -703,7 +703,7 @@ Andrew

]]>
Lima 0 - + @@ -775,7 +775,7 @@ Andrew

]]>
2 - + @@ -799,7 +799,7 @@ Andrew

]]>
0 - + @@ -820,6 +820,8 @@ Andrew

]]>
1 + + @@ -842,6 +844,8 @@ Andrew

]]>
1 + + @@ -892,246 +896,267 @@ Andrew

]]>
- - -lead -Recurring delivery contract -36221 -20 -Max Johnson -max123@itconsult.com - -Milan - -1 - - - - - - + + + lead + Recurring delivery contract + 36221 + 20 + Max Johnson + max123@itconsult.com + + Milan + + 1 + + + + + + + + - - -lead -Need info about pricing -28443 -80 -Aloysius Akred -aakreda@theglobeandmail.com - -London -1 Russel square - -1 - - - - - - + + + lead + Need info about pricing + 28443 + 80 + Aloysius Akred + aakreda@theglobeandmail.com + + London + 1 Russel square + + 1 + + + + + + + + - - -lead -Trelian New Offices -88715 -12 -Rosalynd Oxshott -Photobug -roxshott9@trellian.com - -Canguaretama - -1 - - - - - - + + + lead + Trelian New Offices + 88715 + 12 + Rosalynd Oxshott + Photobug + roxshott9@trellian.com + + Canguaretama + + 1 + + + + + + + + - - -lead -Branded Furniture -7784 -20 -Myrna Limprecht -mlimprecht8@fastcompany.com - -Valejas - -1 - - - - - - + + + lead + Branded Furniture + 7784 + 20 + Myrna Limprecht + mlimprecht8@fastcompany.com + + Valejas + + 1 + + + + + + + + - - -lead -Design New Shelves -54587 -81 -Alys Kalinovich -Jaloo -akalinovich7@tinypic.com - -Boafeo - -1 - - - - - - + + + lead + Design New Shelves + 54587 + 81 + Alys Kalinovich + Jaloo + akalinovich7@tinypic.com + + Boafeo + + 1 + + + + + + + + - - -lead -Office chairs -5474 -20 -Jennine Jobbins -Shufflebeat -jjobbins6@simplemachines.org - -Gvardeysk - -1 - - - - - - + + + lead + Office chairs + 5474 + 20 + Jennine Jobbins + Shufflebeat + jjobbins6@simplemachines.org + + Gvardeysk + + 1 + + + + + + + + - - -lead -Cleaning subscription -11475 -25 -Elmo Espinazo -Realblab -eespinazo5@reuters.com - -Amsterdam - -1 - - - - - - + + + lead + Cleaning subscription + 11475 + 25 + Elmo Espinazo + Realblab + eespinazo5@reuters.com + + Amsterdam + + 1 + + + + + + + + - - -lead -Custom Desks (100 pieces) -9987 -20 -Chalmers Redford -Muxo -credford4@salon.com - -Odoyev - -1 - - - - - - - - + + + lead + Custom Desks (100 pieces) + 9987 + 20 + Chalmers Redford + Muxo + credford4@salon.com + + Odoyev + + 1 + + + + + + + + - - -lead -Need a price: urgent -4482 -90 -Itch Kirvell -Skibox -ikirvell3@gnu.org - -Dahu Satu - -1 - - - - - - + + + lead + Need a price: urgent + 4482 + 90 + Itch Kirvell + Skibox + ikirvell3@gnu.org + + Dahu Satu + + 1 + + + + + + + + - - -lead -Furnitures for new location -22500 -20 -Napoleon Grabert -Twinte -ngrabert2@dailymail.co.uk - -Tokyo - -1 - - - - - - + + + lead + Furnitures for new location + 22500 + 20 + Napoleon Grabert + Twinte + ngrabert2@dailymail.co.uk + + Tokyo + + 1 + + + + + + + + - - -lead -Modernize old offices -99755 -20 -Franny Seiller -Yozio -fseiller1@theglobeandmail.com - -Wurigelebur - -1 - - - - - - + + + lead + Modernize old offices + 99755 + 20 + Franny Seiller + Yozio + fseiller1@theglobeandmail.com + + Wurigelebur + + 1 + + + + + + + + - - - -lead -Quote for 35 windows -41421 -20 -Tommi Brockhouse -Gabcube -tbrockhouse0@google.pl - -Brussels - -1 - - - - - - + + + lead + Quote for 35 windows + 41421 + 20 + Tommi Brockhouse + Gabcube + tbrockhouse0@google.pl + + Brussels + + 1 + + + + + + + + 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 new file mode 100644 index 00000000000..20d33b217ab --- /dev/null +++ b/addons/crm/data/crm_team_member_demo.xml @@ -0,0 +1,13 @@ + + + + + 30 + [['probability', '>=', 20]] + + + 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/__init__.py b/addons/crm/models/__init__.py index 7b1a7fdcd7a..2282f96b1d3 100644 --- a/addons/crm/models/__init__.py +++ b/addons/crm/models/__init__.py @@ -7,6 +7,7 @@ from . import crm_lead from . import crm_lost_reason from . import crm_stage from . import crm_team +from . import crm_team_member from . import ir_config_parameter from . import res_config_settings from . import res_partner diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 1c8cf04fe38..5b15a87d5ad 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -3,16 +3,16 @@ import logging import threading +from collections import OrderedDict, defaultdict from datetime import date, datetime, timedelta from psycopg2 import sql from odoo import api, fields, models, tools, SUPERUSER_ID from odoo.addons.phone_validation.tools import phone_validation +from odoo.exceptions import UserError, AccessError from odoo.osv import expression from odoo.tools.translate import _ -from odoo.tools import email_re, email_split -from odoo.exceptions import UserError, AccessError -from collections import OrderedDict, defaultdict +from odoo.tools import email_re, email_split, safe_eval from . import crm_stage @@ -84,15 +84,24 @@ class Lead(models.Model): 'format.address.mixin', ] _primary_email = 'email_from' + _check_company_auto = True # Description name = fields.Char( 'Opportunity', index=True, required=True, compute='_compute_name', readonly=False, store=True) - user_id = fields.Many2one('res.users', string='Salesperson', index=True, tracking=True, default=lambda self: self.env.user) + user_id = fields.Many2one( + 'res.users', string='Salesperson', default=lambda self: self.env.user, + domain="['&', ('share', '=', False), ('company_ids', 'in', user_company_ids)]", + check_company=True, index=True, tracking=True) + user_company_ids = fields.Many2many( + 'res.company', compute='_compute_user_company_ids', + help='UX: Limit to lead company or all if no company') user_email = fields.Char('User Email', related='user_id.email', readonly=True) user_login = fields.Char('User Login', related='user_id.login', readonly=True) - company_id = fields.Many2one('res.company', string='Company', index=True, default=lambda self: self.env.company.id) + company_id = fields.Many2one( + 'res.company', string='Company', index=True, + compute='_compute_company_id', readonly=False, store=True) referred = fields.Char('Referred By') description = fields.Text('Notes') active = fields.Boolean('Active', default=True, tracking=True) @@ -104,7 +113,8 @@ class Lead(models.Model): crm_stage.AVAILABLE_PRIORITIES, string='Priority', index=True, default=crm_stage.AVAILABLE_PRIORITIES[0][0]) team_id = fields.Many2one( - 'crm.team', string='Sales Team', index=True, tracking=True, + 'crm.team', string='Sales Team', check_company=True, index=True, tracking=True, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", compute='_compute_team_id', readonly=False, store=True) stage_id = fields.Many2one( 'crm.stage', string='Stage', index=True, tracking=True, @@ -149,7 +159,7 @@ class Lead(models.Model): date_deadline = fields.Date('Expected Closing', help="Estimate of the date on which the opportunity will be won.") # Customer / contact partner_id = fields.Many2one( - 'res.partner', string='Customer', index=True, tracking=10, + 'res.partner', string='Customer', check_company=True, index=True, tracking=10, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", help="Linked partner (optional). Usually created when converting the lead. You can find a partner by its Name, TIN, Email or Internal Reference.") partner_is_blacklisted = fields.Boolean('Partner is blacklisted', related='partner_id.is_blacklisted', readonly=True) @@ -240,6 +250,15 @@ class Lead(models.Model): def _search_activity_date_deadline_my(self, operator, operand): return ['&', ('activity_ids.user_id', '=', self._uid), ('activity_ids.date_deadline', operator, operand)] + @api.depends('company_id') + def _compute_user_company_ids(self): + all_companies = self.env['res.company'].search([]) + for lead in self: + if not lead.company_id: + lead.user_company_ids = all_companies + else: + lead.user_company_ids = lead.company_id + @api.depends('user_id', 'type') def _compute_team_id(self): """ When changing the user, also set a team_id or restrict team id @@ -249,12 +268,38 @@ class Lead(models.Model): if not lead.user_id: continue user = lead.user_id - if lead.team_id and user in lead.team_id.member_ids | lead.team_id.user_id: + if lead.team_id and user in (lead.team_id.member_ids | lead.team_id.user_id): continue team_domain = [('use_leads', '=', True)] if lead.type == 'lead' else [('use_opportunities', '=', True)] team = self.env['crm.team']._get_default_team_id(user_id=user.id, domain=team_domain) lead.team_id = team.id + @api.depends('user_id', 'team_id') + def _compute_company_id(self): + """ Compute company_id coherency. """ + for lead in self: + proposal = lead.company_id + + # invalidate wrong configuration: company not in responsible companies or in team company if set + if proposal and lead.user_id and proposal not in lead.user_id.company_ids: + proposal = False + if proposal and lead.team_id.company_id and proposal != lead.team_id.company_id: + proposal = False + + # propose a new company based on responsible, limited by team + if not proposal: + if not lead.user_id or lead.user_id == self.env.user: + proposal = self.env.company + elif lead.user_id: + proposal = lead.user_id.company_id + + if lead.team_id.company_id and proposal != lead.team_id.company_id: + proposal = False + + # set a new company + if lead.company_id != proposal: + lead.company_id = proposal + @api.depends('team_id', 'type') def _compute_stage_id(self): for lead in self: @@ -1075,15 +1120,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 cbdad2f0400..315851adf2e 100644 --- a/addons/crm/models/crm_team.py +++ b/addons/crm/models/crm_team.py @@ -1,12 +1,19 @@ # -*- 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.osv import expression from odoo.tools.safe_eval import safe_eval +_logger = logging.getLogger(__name__) + class Team(models.Model): _name = 'crm.team' @@ -18,6 +25,16 @@ 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_optout = fields.Boolean('Skip auto assignment') + 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), @@ -47,16 +78,10 @@ class Team(models.Model): for team in self: team.lead_unassigned_count = counts.get(team.id, 0) + @api.depends('crm_team_member_ids.lead_month_count') def _compute_lead_all_assigned_month_count(self): - limit_date = datetime.datetime.now() - datetime.timedelta(days=30) - leads_data = self.env['crm.lead'].read_group([ - ('team_id', 'in', self.ids), - ('date_open', '>=', fields.Datetime.to_string(limit_date)), - ('user_id', '!=', False), - ], ['team_id'], ['team_id']) - counts = {datum['team_id'][0]: datum['team_id_count'] for datum in leads_data} for team in self: - team.lead_all_assigned_month_count = counts.get(team.id, 0) + 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([ @@ -88,6 +113,16 @@ 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 = literal_eval(team.assignment_domain or '[]') + if domain: + self.env['crm.lead'].search(domain, limit=1) + except Exception: + raise exceptions.ValidationError(_('Assignment domain for team %(team)s is incorrectly formatted', team=team.name)) + # ------------------------------------------------------------ # ORM # ------------------------------------------------------------ @@ -113,12 +148,374 @@ 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([ + '&', '|', ('use_leads', '=', True), ('use_opportunities', '=', True), + ('assignment_optout', '=', False) + ])._action_assign_leads(work_days=work_days) + return True + + def action_assign_leads(self, work_days=2, log=True): + """ 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) + + # format result messages + logs = self._action_assign_leads_logs(teams_data, members_data) + html_message = '
'.join(logs) + notif_message = ' '.join(logs) + + # log a note in case of manual assign (as this method will mainly be called + # on singleton record set, do not bother doing a specific message per team) + log_action = _("Lead Assignment requested by %(user_name)s", user_name=self.env.user.name) + log_message = "

%s

%s

" % (log_action, html_message) + self._message_log_batch(bodies=dict((team.id, log_message) for team in self)) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'title': _("Leads Assigned"), + 'message': notif_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 _action_assign_leads_logs(self, teams_data, members_data): + """ Tool method to prepare notification about assignment process result. + + :param teams_data: see ``CrmTeam._allocate_leads()``; + :param members_data: see ``CrmTeamMember._assign_and_convert_leads()``; + + :return list: list of formatted logs, ready to be formatted into a nice + plaintext or html message at caller's will + """ + # 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) + members_assigned = sum(len(member_data['assigned']) for member_data in members_data.values()) + + # format user notification + message_parts = [] + # 1- duplicates removal + if duplicates: + message_parts.append(_("%(duplicates)s duplicates leads have been merged.", + duplicates=duplicates)) + + # 2- nothing assigned at all + if not assigned and not members_assigned: + if len(self) == 1: + if not self.assignment_max: + message_parts.append( + _("No allocated leads to %(team_name)s team because it has no capacity. Add capacity to its salespersons.", + team_name=self.name)) + else: + message_parts.append( + _("No allocated leads to %(team_name)s team and its salespersons because no unassigned lead matches its domain.", + team_name=self.name)) + else: + message_parts.append( + _("No allocated leads to any team or salesperson. Check your Sales Teams and Salespersons configuration as well as unassigned leads.")) + + # 3- team allocation + if not assigned and members_assigned: + if len(self) == 1: + message_parts.append( + _("No new lead allocated to %(team_name)s team because no unassigned lead matches its domain.", + team_name=self.name)) + else: + message_parts.append(_("No new lead allocated to the teams because no lead match their domains.")) + elif assigned: + if len(self) == 1: + message_parts.append( + _("%(assigned)s leads allocated to %(team_name)s team.", + assigned=assigned, team_name=self.name)) + else: + message_parts.append( + _("%(assigned)s leads allocated among %(team_count)s teams.", + assigned=assigned, team_count=len(self))) + + # 4- salespersons assignment + if not members_assigned and assigned: + message_parts.append( + _("No lead assigned to salespersons because no unassigned lead matches their domains.")) + elif members_assigned: + message_parts.append( + _("%(members_assigned)s leads assigned among %(member_count)s salespersons.", + members_assigned=members_assigned, member_count=members)) + + return message_parts + + 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; + + * evaluate which teams still need to receive leads. This is based on + team maximum capacity. We consider a team should receive twice its + capacity as leads. That way members will receive leads and can pick + some leads in team unassigned pool of leads; + + Note that leads are assigned in batch meaning a team could receive + leads that could better fit another team. However this heuristics is + 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) + ) + # 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)) + 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( + (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 + ) + # assignment process data + teams_data = dict.fromkeys(remaining_teams, False) + for team in remaining_teams: + teams_data[team] = dict(assigned=set(), merged=set(), duplicates=set()) + + remaining_teams = remaining_teams.filtered('assignment_max') + while remaining_teams: + for team in remaining_teams: + lead_domain = expression.AND([ + teams_domain[team], + [('create_date', '<', max_create_dt)], + ['&', ('team_id', '=', False), ('user_id', '=', False)], + ['|', ('stage_id.is_won', '=', False), ('probability', 'not in', [False, 0])] + ]) + # assign only to reach asked team limit + remaining = teams_limit[team] - (len(teams_data[team]['assigned']) + len(teams_data[team]['merged'])) + lead_limit = min([BUNDLE_SIZE, remaining if remaining > 0 else 1]) + leads = self.env["crm.lead"].search(lead_domain, limit=lead_limit) + + # assign + deduplicate and concatenate results in teams_data to keep some history + assign_res = team._allocate_leads_deduplicate(leads) + _logger.info('Assigned %d leads among %d candidates to team %s' % (len(assign_res['assigned']) + len(assign_res['merged']), len(leads), team.id)) + _logger.info('\tLeads: direct assign %s / merge result %s / duplicates merged: %s' % ( + assign_res['assigned'], assign_res['merged'], assign_res['duplicates'] + )) + for key in ('assigned', 'merged', 'duplicates'): + teams_data[team][key].update(assign_res[key]) + + # either no more lead matching domain, either asked capacity assigned + if len(leads) < lead_limit or (len(teams_data[team]['assigned']) + len(teams_data[team]['merged'])) >= teams_limit[team]: + team_done += team + + # auto-commit except in testing mode. As this process may be time consuming or we + # may encounter errors, already commit what is allocated to avoid endless cron loops. + auto_commit = not getattr(threading.currentThread(), 'testing', False) + 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 # ------------------------------------------------------------ @@ -180,4 +577,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 new file mode 100644 index 00000000000..b4a18ca0396 --- /dev/null +++ b/addons/crm/models/crm_team_member.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import datetime +import logging +import math +import threading +import random + +from ast import literal_eval + +from odoo import api, exceptions, fields, models, _ +from odoo.osv import expression + +_logger = logging.getLogger(__name__) + + +class Team(models.Model): + _inherit = 'crm.team.member' + + # assignment + assignment_enabled = fields.Boolean(related="crm_team_id.assignment_enabled") + assignment_domain = fields.Char('Assignment Domain', tracking=True) + assignment_max = fields.Integer('Max Leads (last 30 days)', default=30) + lead_month_count = fields.Integer( + 'Leads (30 days)', compute='_compute_lead_month_count', + help='Lead assigned to this member those last 30 days') + + @api.depends('user_id', 'crm_team_id') + def _compute_lead_month_count(self): + for member in self: + if member.user_id.id and member.crm_team_id.id: + member.lead_month_count = self.env['crm.lead'].search_count(member._get_lead_month_domain()) + else: + member.lead_month_count = 0 + + @api.constrains('assignment_domain') + def _constrains_assignment_domain(self): + for member in self: + try: + domain = literal_eval(member.assignment_domain or '[]') + if domain: + self.env['crm.lead'].search(domain, limit=1) + except Exception: + raise exceptions.ValidationEreror(_( + 'Member assignment domain for user %(user)s and team %(team)s is incorrectly formatted', + user=member.user_id.name, team=member.crm_team_id.name + )) + + 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), + ('probability', '<', 100) + ] + + # ------------------------------------------------------------ + # 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([ + literal_eval(member.assignment_domain or '[]'), + ['&', '&', ('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 994c37bce9b..078beda0d48 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,6 +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.") @@ -23,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, @@ -61,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([('assignment_optout', '=', False)]).action_assign_leads(work_days=2, log=False) diff --git a/addons/crm/static/src/scss/crm_team_member_views.scss b/addons/crm/static/src/scss/crm_team_member_views.scss new file mode 100644 index 00000000000..4999b609e41 --- /dev/null +++ b/addons/crm/static/src/scss/crm_team_member_views.scss @@ -0,0 +1,6 @@ +.o_kanban_view.o_crm_team_member_kanban { + .o_member_assignment div.oe_gauge { + width: 100px; + height: 80px; + } +} 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 f46e1fd265f..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 @@ -246,10 +308,10 @@ class TestLeadConvertCommon(TestCrmCommon): # Sales Team organization # Role: M (team member) R (team manager) # SALESMAN---------------sales_team_1-----sales_team_convert - # admin------------------M----------------/ + # admin------------------M----------------/ (sales_team_1_m2) # user_sales_manager-----R----------------R - # user_sales_leads-------M----------------/ - # user_sales_salesman----/----------------M + # user_sales_leads-------M----------------/ (sales_team_1_m1) + # user_sales_salesman----/----------------M (sales_team_convert_m1) # Stages Team organization # Name-------------------ST-------------------Sequ @@ -268,7 +330,13 @@ class TestLeadConvertCommon(TestCrmCommon): 'use_opportunities': True, 'company_id': False, 'user_id': cls.user_sales_manager.id, - 'member_ids': [(4, cls.user_sales_salesman.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', @@ -286,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): @@ -295,11 +432,11 @@ class TestLeadConvertMassCommon(TestLeadConvertCommon): # Sales Team organization # Role: M (team member) R (team manager) # SALESMAN-------------------sales_team_1-----sales_team_convert - # admin----------------------M----------------/ - # user_sales_manager---------R----------------R + # admin----------------------M----------------/ (sales_team_1_m2) + # user_sales_manager---------R----------------R (sales_team_1_m1) # user_sales_leads-----------M----------------/ - # user_sales_leads_convert---/----------------M <-- NEW - # user_sales_salesman--------/----------------M + # user_sales_leads_convert---/----------------M <-- NEW (sales_team_convert_m2) + # user_sales_salesman--------/----------------M (sales_team_convert_m1) cls.user_sales_leads_convert = mail_new_test_user( cls.env, login='user_sales_leads_convert', @@ -308,8 +445,9 @@ class TestLeadConvertMassCommon(TestLeadConvertCommon): notification_type='inbox', groups='sales_team.group_sale_salesman_all_leads,base.group_partner_manager,crm.group_use_lead', ) - cls.sales_team_convert.write({ - 'member_ids': [(4, cls.user_sales_leads_convert.id)] + cls.sales_team_convert_m2 = cls.env['crm.team.member'].create({ + 'user_id': cls.user_sales_leads_convert.id, + 'crm_team_id': cls.sales_team_convert.id, }) cls.lead_w_partner = cls.env['crm.lead'].create({ 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..750e3df705d --- /dev/null +++ b/addons/crm/tests/test_crm_lead_assignment.py @@ -0,0 +1,254 @@ +# -*- 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=467): # crm only: 460 + self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2) + + # teams assign + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order + leads_st1 = leads.filtered_domain([('team_id', '=', self.sales_team_1.id)]) + leads_stc = leads.filtered_domain([('team_id', '=', self.sales_team_convert.id)]) + self.assertEqual(len(leads_st1), 10) # 2 * 2 * 75 / 30.0 + self.assertEqual(len(leads_stc), 12) # 2 * 2 * 90 / 30.0 + + # salespersons assign + self.members.invalidate_cache(fnames=['lead_month_count']) + self.assertMemberAssign(self.sales_team_1_m1, 3) # 45 max on 2 days + self.assertMemberAssign(self.sales_team_1_m2, 1) # 15 max on 2 days + 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) + + # teams assign: everything should be done due to duplicates + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order + self.assertTrue(len(leads.filtered_domain([('team_id', '=', False)])) == 0) + + # deduplicate should have removed all duplicated linked to contact_1 and contact_2 + new_assigned_leads_wpartner = self.env['crm.lead'].search([ + ('partner_id', 'in', (self.contact_1 | self.contact_2).ids), + ('id', 'in', leads.ids) + ]) + 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=206): # crm only: 201 + self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2) + + # teams assign + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order + leads_st1 = leads.filtered_domain([('team_id', '=', self.sales_team_1.id)]) + leads_stc = leads.filtered_domain([('team_id', '=', self.sales_team_convert.id)]) + self.assertEqual(len(leads_st1), 10) # 2 * 2 * 75 / 30.0 + self.assertEqual(len(leads_stc), 12) # 2 * 2 * 90 / 30.0 + + # salespersons assign + self.members.invalidate_cache(fnames=['lead_month_count']) + self.assertMemberAssign(self.sales_team_1_m1, 3) # 45 max on 2 days + self.assertMemberAssign(self.sales_team_1_m2, 1) # 15 max on 2 days + 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=6289): # crm only: 6165 + self.env['crm.team'].browse(sales_teams.ids)._action_assign_leads(work_days=30) + + self.members.invalidate_cache(fnames=['lead_month_count']) + 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/tests/test_crm_lead_convert_mass.py b/addons/crm/tests/test_crm_lead_convert_mass.py index a58a60d269d..f22679f8ef0 100644 --- a/addons/crm/tests/test_crm_lead_convert_mass.py +++ b/addons/crm/tests/test_crm_lead_convert_mass.py @@ -24,7 +24,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): with self.assertQueryCount(user_sales_manager=0): test_leads = self.env['crm.lead'].browse(test_leads.ids) - with self.assertQueryCount(user_sales_manager=254): # crm only: 251 + with self.assertQueryCount(user_sales_manager=254): # crm only: 252 test_leads._handle_salesmen_assignment(user_ids=user_ids, team_id=False) self.assertEqual(test_leads.team_id, self.sales_team_convert | self.sales_team_1) @@ -42,7 +42,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): with self.assertQueryCount(user_sales_manager=0): test_leads = self.env['crm.lead'].browse(test_leads.ids) - with self.assertQueryCount(user_sales_manager=220): # crm only: 215 + with self.assertQueryCount(user_sales_manager=220): # crm only: 218 test_leads._handle_salesmen_assignment(user_ids=user_ids, team_id=team_id) self.assertEqual(test_leads.team_id, self.sales_team_convert) @@ -166,7 +166,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): test_leads = self._create_leads_batch(count=50, user_ids=[False]) user_ids = self.assign_users.ids - with self.assertQueryCount(user_sales_manager=1363): # crm only: 1352 + with self.assertQueryCount(user_sales_manager=1367): # crm only: 1357 mass_convert = self.env['crm.lead2opportunity.partner.mass'].with_context({ 'active_model': 'crm.lead', 'active_ids': test_leads.ids, diff --git a/addons/crm/tests/test_crm_ui.py b/addons/crm/tests/test_crm_ui.py index 116163f28ac..04aa368bc1a 100644 --- a/addons/crm/tests/test_crm_ui.py +++ b/addons/crm/tests/test_crm_ui.py @@ -1,10 +1,12 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -import odoo.tests +from odoo.addons.crm.tests.common import TestCrmCommon +from odoo.tests import HttpCase +from odoo.tests.common import tagged, users -@odoo.tests.tagged('post_install','-at_install') -class TestUi(odoo.tests.HttpCase): +@tagged('post_install', '-at_install') +class TestUi(HttpCase): def test_01_crm_tour(self): self.start_tour("/web", 'crm_tour', login="admin") @@ -22,3 +24,10 @@ class TestUi(odoo.tests.HttpCase): ])] }) self.start_tour("/web", 'crm_rainbowman', login="temp_crm_user") + + +class TestCRMLeadMisc(TestCrmCommon): + + @users('user_sales_leads') + def test_team_my_pipeline(self): + action = self.env['crm.team'].action_your_pipeline() diff --git a/addons/crm/views/assets.xml b/addons/crm/views/assets.xml index 1ffed45b2a8..76faa781844 100644 --- a/addons/crm/views/assets.xml +++ b/addons/crm/views/assets.xml @@ -7,7 +7,11 @@ + + + +