]]>
001182
-
+
@@ -249,7 +249,7 @@ ESM Expert ]]>
OX1 1RQ2
-
+
@@ -305,7 +305,7 @@ Andrew]]>
13002
-
+
@@ -604,7 +604,7 @@ Andrew]]>
1
-
+
@@ -667,7 +667,7 @@ Andrew]]>
1
-
+
@@ -703,7 +703,7 @@ Andrew]]>
Lima0
-
+
@@ -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 @@
+
+
+
+
diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml
index a729075970b..6d5de8beea6 100644
--- a/addons/crm/views/crm_lead_views.xml
+++ b/addons/crm/views/crm_lead_views.xml
@@ -213,10 +213,9 @@
-
-
+
@@ -227,6 +226,7 @@
+
@@ -338,7 +338,9 @@
-
+
+
@@ -443,6 +445,7 @@
+
@@ -669,7 +672,9 @@
-
+
+
diff --git a/addons/crm/views/crm_menu_views.xml b/addons/crm/views/crm_menu_views.xml
index 9db695e5e06..eda5f6583b5 100644
--- a/addons/crm/views/crm_menu_views.xml
+++ b/addons/crm/views/crm_menu_views.xml
@@ -35,7 +35,7 @@
id="sales_team_menu_team_pipeline"
name="Teams"
parent="crm_menu_sales"
- action="sales_team.crm_team_salesteams_pipelines_act"
+ action="sales_team.crm_team_action_pipeline"
groups="sales_team.group_sale_manager"
sequence="4"/>
+