213 lines
9.2 KiB
Python
213 lines
9.2 KiB
Python
|
# -*- 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)
|