mirror of
https://github.com/bringout/oca-ocb-crm.git
synced 2026-04-24 16:12:04 +02:00
19.0 vanilla
This commit is contained in:
parent
dc68f80d3f
commit
7221b9ac46
610 changed files with 135477 additions and 161677 deletions
|
|
@ -3,39 +3,59 @@
|
|||
|
||||
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
|
||||
from odoo.tools import float_round
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TeamMember(models.Model):
|
||||
class CrmTeamMember(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_domain_preferred = fields.Char('Preference assignment Domain', tracking=True)
|
||||
assignment_optout = fields.Boolean('Pause assignment')
|
||||
assignment_max = fields.Integer('Average Leads Capacity (on 30 days)', default=30)
|
||||
lead_day_count = fields.Integer(
|
||||
'Leads (last 24h)', compute='_compute_lead_day_count',
|
||||
help='Number of leads assigned to this member in the last 24 hours (lost leads excluded)')
|
||||
lead_month_count = fields.Integer(
|
||||
'Leads (30 days)', compute='_compute_lead_month_count',
|
||||
help='Lead assigned to this member those last 30 days')
|
||||
help='Number of leads assigned to this member in the last 30 days')
|
||||
|
||||
@api.depends('user_id', 'crm_team_id')
|
||||
def _compute_lead_day_count(self):
|
||||
day_date = fields.Datetime.now() - datetime.timedelta(hours=24)
|
||||
daily_leads_counts = self._get_lead_from_date(day_date)
|
||||
|
||||
for member in self:
|
||||
member.lead_day_count = daily_leads_counts.get((member.user_id.id, member.crm_team_id.id), 0)
|
||||
|
||||
@api.depends('user_id', 'crm_team_id')
|
||||
def _compute_lead_month_count(self):
|
||||
month_date = fields.Datetime.now() - datetime.timedelta(days=30)
|
||||
monthly_leads_counts = self._get_lead_from_date(month_date)
|
||||
|
||||
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
|
||||
member.lead_month_count = monthly_leads_counts.get((member.user_id.id, member.crm_team_id.id), 0)
|
||||
|
||||
def _get_lead_from_date(self, date_from, active_test=False):
|
||||
return {
|
||||
(user.id, team.id): count for user, team, count in self.env['crm.lead'].with_context(active_test=active_test)._read_group(
|
||||
[
|
||||
('date_open', '>=', date_from),
|
||||
('team_id', 'in', self.crm_team_id.ids),
|
||||
('user_id', 'in', self.user_id.ids),
|
||||
],
|
||||
['user_id', 'team_id'],
|
||||
['__count'],
|
||||
)
|
||||
}
|
||||
|
||||
@api.constrains('assignment_domain')
|
||||
def _constrains_assignment_domain(self):
|
||||
|
|
@ -50,163 +70,30 @@ class TeamMember(models.Model):
|
|||
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),
|
||||
]
|
||||
@api.constrains('assignment_domain_preferred')
|
||||
def _constrains_assignment_domain_preferred(self):
|
||||
for member in self:
|
||||
try:
|
||||
domain = literal_eval(member.assignment_domain_preferred or '[]')
|
||||
if domain:
|
||||
self.env['crm.lead'].search(domain, limit=1)
|
||||
except Exception:
|
||||
raise exceptions.ValidationError(_(
|
||||
'Member preferred assignment domain for user %(user)s and team %(team)s is incorrectly formatted',
|
||||
user=member.user_id.name, team=member.crm_team_id.name
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 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;
|
||||
}, ...
|
||||
def _get_assignment_quota(self, force_quota=False):
|
||||
""" Return the remaining daily quota based
|
||||
on the assignment_max and the lead already assigned in the past 24h
|
||||
|
||||
:param bool force_quota: see ``CrmTeam._action_assign_leads()``;
|
||||
"""
|
||||
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)
|
||||
quota = float_round(self.assignment_max / 30.0, precision_digits=0, rounding_method='HALF-UP')
|
||||
if force_quota:
|
||||
return quota
|
||||
return quota - self.lead_day_count
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue