crm/models/crm_team_member.py

213 lines
9.2 KiB
Python
Raw Normal View History

# -*- 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 TeamMember(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_optout = fields.Boolean('Skip auto assignment')
assignment_max = fields.Integer('Average Leads Capacity (on 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'].with_context(active_test=False).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.ValidationError(_(
'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),
]
# ------------------------------------------------------------
# LEAD ASSIGNMENT
# ------------------------------------------------------------
def _assign_and_convert_leads(self, work_days=1):
""" 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 float 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 work_days < 0.2 or work_days > 30:
raise ValueError(
_('Leads team allocation should be done for at least 0.2 or maximum 30 work days, not %.2f.', work_days)
)
members_data, population, weights = dict(), list(), list()
members = self.filtered(lambda member: not member.assignment_optout and member.assignment_max > 0)
if not members:
return members_data
# prepare a global lead count based on total leads to assign to salespersons
lead_limit = sum(
member._get_assignment_quota(work_days=work_days)
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, id', limit=lead_limit)
to_assign = member._get_assignment_quota(work_days=work_days)
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
# auto-commit except in testing mode
auto_commit = not getattr(threading.current_thread(), 'testing', False)
commit_bundle_size = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.commit.bundle', 100))
while population and any(weights):
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,
user_ids=member_data['team_member'].user_id.ids
)
if auto_commit and counter % commit_bundle_size == 0:
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()
if auto_commit:
self._cr.commit()
# 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(members))
for member, member_info in result_data.items():
_logger.info('-> member %s: assigned %d leads (%s)', member.id, len(member_info["assigned"]), member_info["assigned"])
return result_data
def _get_assignment_quota(self, work_days=1):
""" Compute assignment quota based on work_days. This quota includes
a compensation to speedup getting to the lead average (``assignment_max``).
As this field is a counter for "30 days" -> divide by requested work
days in order to have base assign number then add compensation.
:param float work_days: see ``CrmTeam.action_assign_leads()``;
"""
assign_ratio = work_days / 30.0
to_assign = self.assignment_max * assign_ratio
compensation = max(0, self.assignment_max - (self.lead_month_count + to_assign)) * 0.2
return round(to_assign + compensation)