mirror of
https://github.com/bringout/oca-ocb-crm.git
synced 2026-04-21 22:32:04 +02:00
19.0 vanilla
This commit is contained in:
parent
dc68f80d3f
commit
7221b9ac46
610 changed files with 135477 additions and 161677 deletions
|
|
@ -1,7 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import res_users
|
||||
from . import calendar
|
||||
from . import crm_lead
|
||||
from . import crm_lost_reason
|
||||
|
|
@ -16,3 +15,4 @@ from . import crm_lead_scoring_frequency
|
|||
from . import utm
|
||||
from . import crm_recurring_plan
|
||||
from . import mail_activity
|
||||
from . import res_users
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ class CalendarEvent(models.Model):
|
|||
event.is_highlighted = True
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals):
|
||||
events = super(CalendarEvent, self).create(vals)
|
||||
def create(self, vals_list):
|
||||
events = super().create(vals_list)
|
||||
for event in events:
|
||||
if event.opportunity_id and not event.activity_ids:
|
||||
event.opportunity_id.log_meeting(event.name, event.start, event.duration)
|
||||
event.opportunity_id.log_meeting(event)
|
||||
return events
|
||||
|
||||
def _is_crm_lead(self, defaults, ctx=None):
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from random import randint
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class LeadScoringFrequency(models.Model):
|
||||
class CrmLeadScoringFrequency(models.Model):
|
||||
_name = 'crm.lead.scoring.frequency'
|
||||
_description = 'Lead Scoring Frequency'
|
||||
|
||||
|
|
@ -12,12 +14,17 @@ class LeadScoringFrequency(models.Model):
|
|||
lost_count = fields.Float('Lost Count', digits=(16, 1)) # Float because we add 0.1 to avoid zero Frequency issue
|
||||
team_id = fields.Many2one('crm.team', 'Sales Team', ondelete="cascade")
|
||||
|
||||
class FrequencyField(models.Model):
|
||||
|
||||
class CrmLeadScoringFrequencyField(models.Model):
|
||||
_name = 'crm.lead.scoring.frequency.field'
|
||||
_description = 'Fields that can be used for predictive lead scoring computation'
|
||||
|
||||
def _get_default_color(self):
|
||||
return randint(1, 11)
|
||||
|
||||
name = fields.Char(related="field_id.field_description")
|
||||
field_id = fields.Many2one(
|
||||
'ir.model.fields', domain=[('model_id.model', '=', 'crm.lead')], required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
color = fields.Integer('Color', default=_get_default_color)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
from odoo import fields, models, _
|
||||
|
||||
|
||||
class LostReason(models.Model):
|
||||
_name = "crm.lost.reason"
|
||||
class CrmLostReason(models.Model):
|
||||
_name = 'crm.lost.reason'
|
||||
_description = 'Opp. Lost Reason'
|
||||
|
||||
name = fields.Char('Description', required=True, translate=True)
|
||||
|
|
@ -16,16 +16,16 @@ class LostReason(models.Model):
|
|||
lead_data = self.env['crm.lead'].with_context(active_test=False)._read_group(
|
||||
[('lost_reason_id', 'in', self.ids)],
|
||||
['lost_reason_id'],
|
||||
['lost_reason_id']
|
||||
['__count'],
|
||||
)
|
||||
mapped_data = dict((data['lost_reason_id'][0], data['lost_reason_id_count']) for data in lead_data)
|
||||
mapped_data = {lost_reason.id: count for lost_reason, count in lead_data}
|
||||
for reason in self:
|
||||
reason.leads_count = mapped_data.get(reason.id, 0)
|
||||
|
||||
def action_lost_leads(self):
|
||||
return {
|
||||
'name': _('Leads'),
|
||||
'view_mode': 'tree,form',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('lost_reason_id', 'in', self.ids)],
|
||||
'res_model': 'crm.lead',
|
||||
'type': 'ir.actions.act_window',
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
from odoo import fields, models
|
||||
|
||||
|
||||
class RecurringPlan(models.Model):
|
||||
_name = "crm.recurring.plan"
|
||||
class CrmRecurringPlan(models.Model):
|
||||
_name = 'crm.recurring.plan'
|
||||
_description = "CRM Recurring revenue plans"
|
||||
_order = "sequence"
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ class RecurringPlan(models.Model):
|
|||
active = fields.Boolean('Active', default=True)
|
||||
sequence = fields.Integer('Sequence', default=10)
|
||||
|
||||
_sql_constraints = [
|
||||
('check_number_of_months', 'CHECK(number_of_months >= 0)', 'The number of month can\'t be negative.'),
|
||||
]
|
||||
_check_number_of_months = models.Constraint(
|
||||
'CHECK(number_of_months >= 0)',
|
||||
"The number of month can't be negative.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
AVAILABLE_PRIORITIES = [
|
||||
('0', 'Low'),
|
||||
|
|
@ -11,40 +11,57 @@ AVAILABLE_PRIORITIES = [
|
|||
]
|
||||
|
||||
|
||||
class Stage(models.Model):
|
||||
class CrmStage(models.Model):
|
||||
""" Model for case stages. This models the main stages of a document
|
||||
management flow. Main CRM objects (leads, opportunities, project
|
||||
issues, ...) will now use only stages, instead of state and stages.
|
||||
Stages are for example used to display the kanban view of records.
|
||||
"""
|
||||
_name = "crm.stage"
|
||||
_name = 'crm.stage'
|
||||
_description = "CRM Stages"
|
||||
_rec_name = 'name'
|
||||
_order = "sequence, name, id"
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
""" As we have lots of default_team_id in context used to filter out
|
||||
leads and opportunities, we pop this key from default of stage creation.
|
||||
Otherwise stage will be created for a given team only which is not the
|
||||
standard behavior of stages. """
|
||||
if 'default_team_id' in self.env.context:
|
||||
ctx = dict(self.env.context)
|
||||
ctx.pop('default_team_id')
|
||||
self = self.with_context(ctx)
|
||||
return super(Stage, self).default_get(fields)
|
||||
|
||||
name = fields.Char('Stage Name', required=True, translate=True)
|
||||
sequence = fields.Integer('Sequence', default=1, help="Used to order stages. Lower is better.")
|
||||
is_won = fields.Boolean('Is Won Stage?')
|
||||
rotting_threshold_days = fields.Integer('Days to rot', default=0, help='Highlight opportunities that haven\'t been updated for this many days. \
|
||||
Set to 0 to disable. Changing this parameter will not affect the rotting status/date of resources last updated before this change.')
|
||||
requirements = fields.Text('Requirements', help="Enter here the internal requirements for this stage (ex: Offer sent to customer). It will appear as a tooltip over the stage's name.")
|
||||
team_id = fields.Many2one('crm.team', string='Sales Team', ondelete="set null",
|
||||
help='Specific team that uses this stage. Other teams will not be able to see or use this stage.')
|
||||
team_ids = fields.Many2many('crm.team', string='Sales Teams', ondelete='restrict')
|
||||
fold = fields.Boolean('Folded in Pipeline',
|
||||
help='This stage is folded in the kanban view when there are no records in that stage to display.')
|
||||
# This field for interface only
|
||||
team_count = fields.Integer('team_count', compute='_compute_team_count')
|
||||
color = fields.Integer(string='Color', export_string_translation=False)
|
||||
|
||||
@api.depends('team_id')
|
||||
@api.depends('team_ids')
|
||||
def _compute_team_count(self):
|
||||
self.team_count = self.env['crm.team'].search_count([])
|
||||
|
||||
@api.onchange('is_won')
|
||||
def _onchange_is_won(self):
|
||||
return {
|
||||
'warning': {
|
||||
'title': _("Do you really want to update this stage?"),
|
||||
'message': _("Changing the value of 'Is Won Stage' may induce a large number of operations, "
|
||||
"as the probabilities of opportunities in this stage will be recomputed on saving."),
|
||||
}
|
||||
}
|
||||
|
||||
def write(self, vals):
|
||||
""" Since leads that are in a won stage must have their
|
||||
probability = 100%, this override ensures that setting a stage as won
|
||||
will set all the leads in that stage to probability = 100%.
|
||||
Inversely, if a won stage is not marked as won anymore, the lead
|
||||
probability should be recomputed based on automated probability.
|
||||
Note: If a user sets a stage as won and changes his mind right after,
|
||||
the manual probability will be lost in the process."""
|
||||
res = super().write(vals)
|
||||
if 'is_won' in vals:
|
||||
won_leads = self.env['crm.lead'].search([('stage_id', 'in', self.ids)])
|
||||
if won_leads and vals.get('is_won'):
|
||||
won_leads.write({'probability': 100, 'automated_probability': 100})
|
||||
elif won_leads and not vals.get('is_won'):
|
||||
won_leads._compute_probabilities()
|
||||
return res
|
||||
|
|
|
|||
|
|
@ -1,31 +1,28 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
|
||||
from ast import literal_eval
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, exceptions, fields, models, _
|
||||
from odoo.osv import expression
|
||||
from odoo import api, exceptions, fields, models, modules, _
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import float_compare, float_round
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
class CrmTeam(models.Model):
|
||||
_name = 'crm.team'
|
||||
_inherit = ['mail.alias.mixin', 'crm.team']
|
||||
_description = 'Sales Team'
|
||||
|
||||
use_leads = fields.Boolean('Leads', help="Check this box to filter and qualify incoming requests as leads before converting them into opportunities and assigning them to a salesperson.")
|
||||
use_opportunities = fields.Boolean('Pipeline', default=True, help="Check this box to manage a presales process with opportunities.")
|
||||
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.")
|
||||
alias_id = fields.Many2one(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')
|
||||
|
|
@ -42,18 +39,9 @@ class Team(models.Model):
|
|||
lead_all_assigned_month_count = fields.Integer(
|
||||
string='# Leads/Opps assigned this month', compute='_compute_lead_all_assigned_month_count',
|
||||
help="Number of leads and opportunities assigned this last month.")
|
||||
opportunities_count = fields.Integer(
|
||||
string='# Opportunities', compute='_compute_opportunities_data')
|
||||
opportunities_amount = fields.Monetary(
|
||||
string='Opportunities Revenues', compute='_compute_opportunities_data')
|
||||
opportunities_overdue_count = fields.Integer(
|
||||
string='# Overdue Opportunities', compute='_compute_opportunities_overdue_data')
|
||||
opportunities_overdue_amount = fields.Monetary(
|
||||
string='Overdue Opportunities Revenues', compute='_compute_opportunities_overdue_data',)
|
||||
# alias: improve fields coming from _inherits, use inherited to avoid replacing them
|
||||
alias_user_id = fields.Many2one(
|
||||
'res.users', related='alias_id.alias_user_id', readonly=False, inherited=True,
|
||||
domain=lambda self: [('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman_all_leads').id)])
|
||||
lead_all_assigned_month_exceeded = fields.Boolean('Exceed monthly lead assignement', compute="_compute_lead_all_assigned_month_count",
|
||||
help="True if the monthly lead assignment count is greater than the maximum assignment limit, false otherwise."
|
||||
)
|
||||
# properties
|
||||
lead_properties_definition = fields.PropertiesDefinition('Lead Properties')
|
||||
|
||||
|
|
@ -63,7 +51,7 @@ class Team(models.Model):
|
|||
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)
|
||||
assign_enabled = self.env['crm.lead']._is_rule_based_assignment_activated()
|
||||
auto_assign_enabled = False
|
||||
if assign_enabled:
|
||||
assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False)
|
||||
|
|
@ -74,42 +62,17 @@ class Team(models.Model):
|
|||
def _compute_lead_unassigned_count(self):
|
||||
leads_data = self.env['crm.lead']._read_group([
|
||||
('team_id', 'in', self.ids),
|
||||
('type', '=', 'lead'),
|
||||
('user_id', '=', False),
|
||||
], ['team_id'], ['team_id'])
|
||||
counts = {datum['team_id'][0]: datum['team_id_count'] for datum in leads_data}
|
||||
], ['team_id'], ['__count'])
|
||||
counts = {team.id: count for team, count in leads_data}
|
||||
for team in self:
|
||||
team.lead_unassigned_count = counts.get(team.id, 0)
|
||||
|
||||
@api.depends('crm_team_member_ids.lead_month_count')
|
||||
@api.depends('crm_team_member_ids.lead_month_count', 'assignment_max')
|
||||
def _compute_lead_all_assigned_month_count(self):
|
||||
for team in self:
|
||||
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([
|
||||
('team_id', 'in', self.ids),
|
||||
('probability', '<', 100),
|
||||
('type', '=', 'opportunity'),
|
||||
], ['expected_revenue:sum', 'team_id'], ['team_id'])
|
||||
counts = {datum['team_id'][0]: datum['team_id_count'] for datum in opportunity_data}
|
||||
amounts = {datum['team_id'][0]: datum['expected_revenue'] for datum in opportunity_data}
|
||||
for team in self:
|
||||
team.opportunities_count = counts.get(team.id, 0)
|
||||
team.opportunities_amount = amounts.get(team.id, 0)
|
||||
|
||||
def _compute_opportunities_overdue_data(self):
|
||||
opportunity_data = self.env['crm.lead']._read_group([
|
||||
('team_id', 'in', self.ids),
|
||||
('probability', '<', 100),
|
||||
('type', '=', 'opportunity'),
|
||||
('date_deadline', '<', fields.Date.to_string(fields.Datetime.now()))
|
||||
], ['expected_revenue', 'team_id'], ['team_id'])
|
||||
counts = {datum['team_id'][0]: datum['team_id_count'] for datum in opportunity_data}
|
||||
amounts = {datum['team_id'][0]: (datum['expected_revenue']) for datum in opportunity_data}
|
||||
for team in self:
|
||||
team.opportunities_overdue_count = counts.get(team.id, 0)
|
||||
team.opportunities_overdue_amount = amounts.get(team.id, 0)
|
||||
team.lead_all_assigned_month_exceeded = team.lead_all_assigned_month_count > team.assignment_max
|
||||
|
||||
@api.onchange('use_leads', 'use_opportunities')
|
||||
def _onchange_use_leads_opportunities(self):
|
||||
|
|
@ -131,7 +94,7 @@ class Team(models.Model):
|
|||
# ------------------------------------------------------------
|
||||
|
||||
def write(self, vals):
|
||||
result = super(Team, self).write(vals)
|
||||
result = super().write(vals)
|
||||
if 'use_leads' in vals or 'use_opportunities' in vals:
|
||||
for team in self:
|
||||
alias_vals = team._alias_get_creation_values()
|
||||
|
|
@ -175,14 +138,14 @@ class Team(models.Model):
|
|||
'variable': frequency.variable,
|
||||
'won_count': frequency.won_count if float_compare(frequency.won_count, 0.1, 2) == 1 else 0.1,
|
||||
})
|
||||
return super(Team, self).unlink()
|
||||
return super().unlink()
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# MESSAGING
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def _alias_get_creation_values(self):
|
||||
values = super(Team, self)._alias_get_creation_values()
|
||||
values = super()._alias_get_creation_values()
|
||||
values['alias_model_id'] = self.env['ir.model']._get('crm.lead').id
|
||||
if self.id:
|
||||
if not self.use_leads and not self.use_opportunities:
|
||||
|
|
@ -198,51 +161,30 @@ class Team(models.Model):
|
|||
# ------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _cron_assign_leads(self, work_days=None):
|
||||
def _cron_assign_leads(self, force_quota=False, creation_delta_days=7):
|
||||
""" Cron method assigning leads. Leads are allocated to all teams and
|
||||
assigned to their members. It is based on either cron configuration
|
||||
either forced through ``work_days`` parameter.
|
||||
assigned to their members.
|
||||
|
||||
When based on cron configuration purpose of cron is to assign leads to
|
||||
sales persons. Assigned workload is set to the workload those sales
|
||||
people should perform between two cron iterations. If their maximum
|
||||
capacity is reached assign process will not assign them any more lead.
|
||||
|
||||
e.g. cron is active with interval_number 3, interval_type days. This
|
||||
means cron runs every 3 days. Cron will assign leads for 3 work days
|
||||
to salespersons each 3 days unless their maximum capacity is reached.
|
||||
|
||||
If cron runs on an hour- or minute-based schedule minimum assignment
|
||||
performed is equivalent to 0.2 workdays to avoid rounding issues.
|
||||
Max assignment performed is for 30 days as it is better to run more
|
||||
often than planning for more than one month. Assign process is best
|
||||
designed to run every few hours (~4 times / day) or each few days.
|
||||
The cron is designed to run at least once a day or more.
|
||||
A number of leads will be assigned each time depending on the daily leads
|
||||
already assigned.
|
||||
This allows the assignment process based on the cron to work on a daily basis
|
||||
without allocating too much leads on members if the cron is executed multiple
|
||||
times a day.
|
||||
The daily quota of leads can be forcefully assigned with force_quota
|
||||
(ignoring the daily leads already assigned).
|
||||
|
||||
See ``CrmTeam.action_assign_leads()`` and its sub methods for more
|
||||
details about assign process.
|
||||
|
||||
:param float work_days: see ``CrmTeam.action_assign_leads()``;
|
||||
"""
|
||||
assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False)
|
||||
if not work_days and 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 = min(30, assign_cron.interval_number * 7) # max at 30 (better lead repartition)
|
||||
elif assign_cron.interval_type == 'days':
|
||||
work_days = min(30, assign_cron.interval_number * 1) # max at 30 (better lead repartition)
|
||||
elif assign_cron.interval_type == 'hours':
|
||||
work_days = max(0.2, assign_cron.interval_number / 24) # min at 0.2 to avoid small numbers issues
|
||||
elif assign_cron.interval_type == 'minutes':
|
||||
work_days = max(0.2, assign_cron.interval_number / 1440) # min at 0.2 to avoid small numbers issues
|
||||
work_days = work_days if work_days else 1 # avoid void values
|
||||
self.env['crm.team'].search([
|
||||
'&', '|', ('use_leads', '=', True), ('use_opportunities', '=', True),
|
||||
('assignment_optout', '=', False)
|
||||
])._action_assign_leads(work_days=work_days)
|
||||
])._action_assign_leads(force_quota=force_quota, creation_delta_days=creation_delta_days)
|
||||
return True
|
||||
|
||||
def action_assign_leads(self, work_days=1, log=True):
|
||||
def action_assign_leads(self):
|
||||
""" Manual (direct) leads assignment. This method both
|
||||
|
||||
* assigns leads to teams given by self;
|
||||
|
|
@ -250,26 +192,20 @@ class Team(models.Model):
|
|||
|
||||
See sub methods for more details about assign process.
|
||||
|
||||
:param float 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
|
||||
:returns: action, a client notification giving some insights on assign
|
||||
process;
|
||||
"""
|
||||
teams_data, members_data = self._action_assign_leads(work_days=work_days)
|
||||
teams_data, members_data = self._action_assign_leads(force_quota=True, creation_delta_days=0)
|
||||
|
||||
# format result messages
|
||||
logs = self._action_assign_leads_logs(teams_data, members_data)
|
||||
html_message = '<br />'.join(logs)
|
||||
html_message = Markup('<br />').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 = "<p>%s<br /><br />%s</p>" % (log_action, html_message)
|
||||
log_message = Markup("<p>%s<br /><br />%s</p>") % (log_action, html_message)
|
||||
self._message_log_batch(bodies=dict((team.id, log_message) for team in self))
|
||||
|
||||
return {
|
||||
|
|
@ -285,7 +221,7 @@ class Team(models.Model):
|
|||
}
|
||||
}
|
||||
|
||||
def _action_assign_leads(self, work_days=1):
|
||||
def _action_assign_leads(self, force_quota=False, creation_delta_days=7):
|
||||
""" Private method for lead assignment. This method both
|
||||
|
||||
* assigns leads to teams given by self;
|
||||
|
|
@ -293,19 +229,27 @@ class Team(models.Model):
|
|||
|
||||
See sub methods for more details about assign process.
|
||||
|
||||
:param float work_days: see ``CrmTeam.action_assign_leads()``;
|
||||
:param bool force_quota: Assign the full daily quota without taking into account
|
||||
the leads already assigned today
|
||||
:param int creation_delta_days: Take into account all leads created in the last nb days (by default 7).
|
||||
If set to zero we take all the past 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``;
|
||||
:returns: 2-elements tuple (teams_data, members_data) as a
|
||||
structure-based result of assignment process. For more details
|
||||
about data see :meth:`CrmTeam._allocate_leads` and
|
||||
:meth:`CrmTeam._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'):
|
||||
if not (self.env.user.has_group('sales_team.group_sale_manager') or self.env.is_system()):
|
||||
raise exceptions.UserError(_('Lead/Opportunities automatic assignment is limited to managers or administrators'))
|
||||
|
||||
_logger.info('### START Lead Assignment (%d teams, %d sales persons, %.2f work_days)', len(self), len(self.crm_team_member_ids), work_days)
|
||||
teams_data = self._allocate_leads(work_days=work_days)
|
||||
_logger.info(
|
||||
'### START Lead Assignment (%d teams, %d sales persons, force daily quota: %s)',
|
||||
len(self),
|
||||
len(self.crm_team_member_ids),
|
||||
"ON" if force_quota else "OFF")
|
||||
teams_data = self._allocate_leads(creation_delta_days=creation_delta_days)
|
||||
_logger.info('### Team repartition done. Starting salesmen assignment.')
|
||||
members_data = self.crm_team_member_ids._assign_and_convert_leads(work_days=work_days)
|
||||
members_data = self._assign_and_convert_leads(force_quota=force_quota)
|
||||
_logger.info('### END Lead Assignment')
|
||||
return teams_data, members_data
|
||||
|
||||
|
|
@ -313,10 +257,11 @@ class Team(models.Model):
|
|||
""" 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()``;
|
||||
:param members_data: see ``CrmTeam._assign_and_convert_leads()``;
|
||||
|
||||
:return list: list of formatted logs, ready to be formatted into a nice
|
||||
:returns: list of formatted logs, ready to be formatted into a nice
|
||||
plaintext or html message at caller's will
|
||||
:rtype: list[str]
|
||||
"""
|
||||
# extract some statistics
|
||||
assigned = sum(len(teams_data[team]['assigned']) + len(teams_data[team]['merged']) for team in teams_data)
|
||||
|
|
@ -375,7 +320,7 @@ class Team(models.Model):
|
|||
|
||||
return message_parts
|
||||
|
||||
def _allocate_leads(self, work_days=1):
|
||||
def _allocate_leads(self, creation_delta_days=7):
|
||||
""" 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
|
||||
|
|
@ -387,8 +332,9 @@ class Team(models.Model):
|
|||
Heuristic of this method is the following:
|
||||
* find unassigned leads for each team, 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;
|
||||
* not won nor inactive -> live leads;
|
||||
* created in the last creation_delta_days (in the last week by default)
|
||||
This avoid to take into account old leads in the allocation.
|
||||
* if set, a delay after creation can be applied (see BUNDLE_HOURS_DELAY)
|
||||
parameter explanations here below;
|
||||
* matching the team's assignment domain (empty means
|
||||
|
|
@ -416,38 +362,47 @@ class Team(models.Model):
|
|||
allocation will be proportional to their size (assignment of their
|
||||
members).
|
||||
|
||||
:config int crm.assignment.bundle: deprecated
|
||||
:config int crm.assignment.commit.bundle: optional config parameter allowing
|
||||
to set size of lead batch to be committed together. By default 100
|
||||
which is a good trade-off between transaction time and speed
|
||||
: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;
|
||||
Supported ``ir.config_parameter`` settings.
|
||||
|
||||
:param float work_days: see ``CrmTeam.action_assign_leads()``;
|
||||
``crm.assignment.bundle``
|
||||
deprecated
|
||||
|
||||
``crm.assignment.commit.bundle`` (``int``)
|
||||
Allow to set size of lead batch to be committed together. By
|
||||
default 100 which is a good trade-off between transaction time and
|
||||
speed.
|
||||
|
||||
``crm.assignment.delay`` (``float``)
|
||||
Give a delay before taking a lead into assignment process
|
||||
(BUNDLE_HOURS_DELAY) given in hours. Purpose if to allow other
|
||||
crons or automation rules to make their job. This option is mainly
|
||||
historic as its purpose was to let automation rules prepare leads
|
||||
and score before PLS was added into CRM. This is now not required
|
||||
anymore but still supported;
|
||||
|
||||
:param int creation_delta_days: see ``CrmTeam._action_assign_leads()``;
|
||||
|
||||
:rtype: dict[str, Any]
|
||||
:return: dictionary mapping each team with assignment result:
|
||||
|
||||
``assigned`` (``set[int]``)
|
||||
Lead IDs directly assigned to the team
|
||||
(no duplicate or merged found)
|
||||
|
||||
``merged`` (``set[int]``)
|
||||
Lead IDs merged and assigned to the team
|
||||
(main leads being results of merge process)
|
||||
|
||||
``duplicates`` (``set[int]``)
|
||||
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
|
||||
|
||||
: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 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)
|
||||
)
|
||||
|
||||
BUNDLE_HOURS_DELAY = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.delay', default=0))
|
||||
BUNDLE_HOURS_DELAY = float(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.delay', default=0))
|
||||
BUNDLE_COMMIT_SIZE = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.commit.bundle', 100))
|
||||
auto_commit = not getattr(threading.current_thread(), 'testing', False)
|
||||
auto_commit = not modules.module.current_test
|
||||
|
||||
# leads
|
||||
max_create_dt = self.env.cr.now() - datetime.timedelta(hours=BUNDLE_HOURS_DELAY)
|
||||
|
|
@ -459,16 +414,18 @@ class Team(models.Model):
|
|||
if not team.assignment_max:
|
||||
continue
|
||||
|
||||
lead_domain = expression.AND([
|
||||
lead_domain = Domain.AND([
|
||||
literal_eval(team.assignment_domain or '[]'),
|
||||
[('create_date', '<=', max_create_dt)],
|
||||
['&', ('team_id', '=', False), ('user_id', '=', False)],
|
||||
['|', ('stage_id', '=', False), ('stage_id.is_won', '=', False)]
|
||||
[('won_status', '!=', 'won')]
|
||||
])
|
||||
if creation_delta_days > 0:
|
||||
lead_domain &= Domain('create_date', '>', self.env.cr.now() - datetime.timedelta(days=creation_delta_days))
|
||||
|
||||
leads = self.env["crm.lead"].search(lead_domain)
|
||||
# Fill duplicate cache: search for duplicate lead before the assignation
|
||||
# avoid to flush during the search at every assignation
|
||||
# Fill duplicate cache: search for duplicate lead before the assignment
|
||||
# avoid to flush during the search at every assignment
|
||||
for lead in leads:
|
||||
if lead not in duplicates_lead_cache:
|
||||
duplicates_lead_cache[lead] = lead._get_lead_duplicates(email=lead.email_from)
|
||||
|
|
@ -487,7 +444,7 @@ class Team(models.Model):
|
|||
# and the first commit occur at the end of the bundle,
|
||||
# the first transaction can be long which we want to avoid
|
||||
if auto_commit:
|
||||
self._cr.commit()
|
||||
self.env.cr.commit()
|
||||
|
||||
# assignment process data
|
||||
global_data = dict(assigned=set(), merged=set(), duplicates=set())
|
||||
|
|
@ -519,13 +476,13 @@ class Team(models.Model):
|
|||
# unlink duplicates once
|
||||
self.env['crm.lead'].browse(lead_unlink_ids).unlink()
|
||||
lead_unlink_ids = set()
|
||||
self._cr.commit()
|
||||
self.env.cr.commit()
|
||||
|
||||
# unlink duplicates once
|
||||
self.env['crm.lead'].browse(lead_unlink_ids).unlink()
|
||||
|
||||
if auto_commit:
|
||||
self._cr.commit()
|
||||
self.env.cr.commit()
|
||||
|
||||
# some final log
|
||||
_logger.info('## Assigned %s leads', (len(global_data['assigned']) + len(global_data['merged'])))
|
||||
|
|
@ -587,6 +544,151 @@ class Team(models.Model):
|
|||
'duplicates': leads_dup_ids,
|
||||
}
|
||||
|
||||
def _get_lead_to_assign_domain(self):
|
||||
return [
|
||||
('user_id', '=', False),
|
||||
('date_open', '=', False),
|
||||
('team_id', 'in', self.ids),
|
||||
]
|
||||
|
||||
def _assign_and_convert_leads(self, force_quota=False):
|
||||
""" 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.
|
||||
|
||||
This method follows the following heuristic
|
||||
* Get quota per member
|
||||
* Find all leads to be assigned per team
|
||||
* Sort list of members per number of leads received in the last 24h
|
||||
* Assign the lead using round robin
|
||||
* Find the first member with a compatible domain
|
||||
* Assign the lead
|
||||
* Move the member at the end of the list if quota is not reached
|
||||
* Remove it otherwise
|
||||
* Move to the next lead
|
||||
|
||||
:param bool force_quota: see ``CrmTeam._action_assign_leads()``;
|
||||
|
||||
:returns: dict() with each member assignment result:
|
||||
membership: {
|
||||
'assigned': set of lead IDs directly assigned to the member;
|
||||
}, ...
|
||||
|
||||
"""
|
||||
auto_commit = not modules.module.current_test
|
||||
result_data = {}
|
||||
commit_bundle_size = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.commit.bundle', 100))
|
||||
teams_with_members = self.filtered(lambda team: team.crm_team_member_ids)
|
||||
quota_per_member = {member: member._get_assignment_quota(force_quota=force_quota) for member in self.crm_team_member_ids}
|
||||
counter = 0
|
||||
leads_per_team = dict(self.env['crm.lead']._read_group(
|
||||
teams_with_members._get_lead_to_assign_domain(),
|
||||
['team_id'],
|
||||
# Do not use recordset aggregation to avoid fetching all the leads at once in memory
|
||||
# We want to have in memory only leads for the current team
|
||||
# and make sure we need them before fetching them
|
||||
['id:array_agg'],
|
||||
))
|
||||
|
||||
def _assign_lead(lead, members, member_leads, members_quota, assign_lst, optional_lst=None):
|
||||
""" Find relevant member whose domain(s) accept the lead. If found convert
|
||||
and update internal structures accordingly. """
|
||||
member_found = next((member for member in members if lead in member_leads[member]), False)
|
||||
if not member_found:
|
||||
return
|
||||
lead.with_context(mail_auto_subscribe_no_notify=True).convert_opportunity(
|
||||
lead.partner_id,
|
||||
user_ids=member_found.user_id.ids
|
||||
)
|
||||
result_data[member_found]['assigned'] += lead
|
||||
|
||||
# if member still has quota, move at end of list; otherwise just remove
|
||||
assign_lst.remove(member_found)
|
||||
if optional_lst is not None:
|
||||
optional_lst.remove(member_found)
|
||||
members_quota[member_found] -= 1
|
||||
if members_quota[member_found] > 0:
|
||||
assign_lst.append(member_found)
|
||||
if optional_lst is not None:
|
||||
optional_lst.append(member_found)
|
||||
return member_found
|
||||
|
||||
for team, leads_to_assign_ids in leads_per_team.items():
|
||||
members_to_assign = list(team.crm_team_member_ids.filtered(lambda member:
|
||||
not member.assignment_optout and quota_per_member.get(member, 0) > 0
|
||||
).sorted(key=lambda member: quota_per_member.get(member, 0), reverse=True))
|
||||
if not members_to_assign:
|
||||
continue
|
||||
result_data.update({
|
||||
member: {"assigned": self.env["crm.lead"], "quota": quota_per_member[member]}
|
||||
for member in members_to_assign
|
||||
})
|
||||
# Need to check that record still exists since the ids have been fetched at the beginning of the process
|
||||
# Previous iteration has committed the change, records may have been deleted in the meanwhile
|
||||
to_assign = self.env['crm.lead'].browse(leads_to_assign_ids).exists()
|
||||
|
||||
members_to_assign_wpref = [
|
||||
m for m in members_to_assign
|
||||
if m.assignment_domain_preferred and literal_eval(m.assignment_domain_preferred or '')
|
||||
]
|
||||
preferred_leads_per_member = {
|
||||
member: to_assign.filtered_domain(
|
||||
Domain.AND([
|
||||
literal_eval(member.assignment_domain or '[]'),
|
||||
literal_eval(member.assignment_domain_preferred)
|
||||
])
|
||||
) for member in members_to_assign_wpref
|
||||
}
|
||||
preferred_leads = self.env['crm.lead'].concat(*[lead for lead in preferred_leads_per_member.values()])
|
||||
assigned_preferred_leads = self.env['crm.lead']
|
||||
|
||||
# first assign loop: preferred leads, always priority
|
||||
for lead in preferred_leads.sorted(lambda lead: (-lead.probability, id)):
|
||||
counter += 1
|
||||
member_found = _assign_lead(lead, members_to_assign_wpref, preferred_leads_per_member, quota_per_member, members_to_assign, members_to_assign_wpref)
|
||||
if not member_found:
|
||||
continue
|
||||
assigned_preferred_leads += lead
|
||||
if auto_commit and counter % commit_bundle_size == 0:
|
||||
self.env.cr.commit()
|
||||
|
||||
# second assign loop: fill up with other leads
|
||||
to_assign = to_assign - assigned_preferred_leads
|
||||
leads_per_member = {
|
||||
member: to_assign.filtered_domain(literal_eval(member.assignment_domain or '[]'))
|
||||
for member in members_to_assign
|
||||
}
|
||||
for lead in to_assign.sorted(lambda lead: (-lead.probability, id)):
|
||||
counter += 1
|
||||
member_found = _assign_lead(lead, members_to_assign, leads_per_member, quota_per_member, members_to_assign)
|
||||
if not member_found:
|
||||
continue
|
||||
if auto_commit and counter % commit_bundle_size == 0:
|
||||
self.env.cr.commit()
|
||||
|
||||
# Make sure we commit at least at the end of the team
|
||||
if auto_commit:
|
||||
self.env.cr.commit()
|
||||
# Once we are done with a team we don't need to keep the leads in memory
|
||||
# Try to avoid to explode memory usage
|
||||
self.env.invalidate_all()
|
||||
_logger.info(
|
||||
'Team %s: Assigned %s leads based on preference, on a potential of %s (limited by quota)',
|
||||
team.name, len(assigned_preferred_leads), len(preferred_leads)
|
||||
)
|
||||
_logger.info(
|
||||
'Assigned %s leads to %s salesmen',
|
||||
sum(len(r['assigned']) for r in result_data.values()), len(result_data)
|
||||
)
|
||||
for member, member_info in result_data.items():
|
||||
_logger.info(
|
||||
'-> member %s of team %s: assigned %d/%d leads (%s)',
|
||||
member.id, member.crm_team_id.id, len(member_info["assigned"]), member_info["quota"], member_info["assigned"]
|
||||
)
|
||||
return result_data
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# ACTIONS
|
||||
# ------------------------------------------------------------
|
||||
|
|
@ -602,61 +704,56 @@ class Team(models.Model):
|
|||
action = self.env['ir.actions.actions']._for_xml_id('crm.crm_lead_action_forecast')
|
||||
return self._action_update_to_pipeline(action)
|
||||
|
||||
def action_open_leads(self):
|
||||
action = self.env['ir.actions.actions']._for_xml_id('crm.crm_case_form_view_salesteams_opportunity')
|
||||
rcontext = {
|
||||
'team': self,
|
||||
}
|
||||
action['help'] = self.env['ir.ui.view']._render_template('crm.crm_action_helper', values=rcontext)
|
||||
return action
|
||||
|
||||
def action_open_unassigned_leads(self):
|
||||
action = self.action_open_leads()
|
||||
context_str = action.get('context', '{}')
|
||||
if context_str:
|
||||
try:
|
||||
context = safe_eval(action['context'], {'active_id': self.id, 'uid': self.env.uid})
|
||||
except (NameError, ValueError):
|
||||
context = {}
|
||||
else:
|
||||
context = {}
|
||||
action['context'] = context | {'search_default_unassigned': True}
|
||||
return action
|
||||
|
||||
@api.model
|
||||
def _action_update_to_pipeline(self, action):
|
||||
self.check_access("read")
|
||||
user_team_id = self.env.user.sale_team_id.id
|
||||
if not user_team_id:
|
||||
user_team_id = self.search([], limit=1).id
|
||||
action['help'] = "<p class='o_view_nocontent_smiling_face'>%s</p><p>" % _("Create an Opportunity")
|
||||
if user_team_id:
|
||||
if self.user_has_groups('sales_team.group_sale_manager'):
|
||||
if self.env.user.has_group('sales_team.group_sale_manager'):
|
||||
action['help'] += "<p>%s</p>" % _("""As you are a member of no Sales Team, you are showed the Pipeline of the <b>first team by default.</b>
|
||||
To work with the CRM, you should <a name="%d" type="action" tabindex="-1">join a team.</a>""",
|
||||
self.env.ref('sales_team.crm_team_action_config').id)
|
||||
else:
|
||||
action['help'] += "<p>%s</p>" % _("""As you are a member of no Sales Team, you are showed the Pipeline of the <b>first team by default.</b>
|
||||
To work with the CRM, you should join a team.""")
|
||||
action_context = safe_eval(action['context'], {'uid': self.env.uid})
|
||||
try:
|
||||
action_context = safe_eval(action['context'], {'uid': self.env.uid})
|
||||
except (NameError, ValueError):
|
||||
action_context = {}
|
||||
action['context'] = action_context
|
||||
return action
|
||||
|
||||
def _compute_dashboard_button_name(self):
|
||||
super(Team, self)._compute_dashboard_button_name()
|
||||
super()._compute_dashboard_button_name()
|
||||
team_with_pipelines = self.filtered(lambda el: el.use_opportunities)
|
||||
team_with_pipelines.update({'dashboard_button_name': _("Pipeline")})
|
||||
|
||||
def action_primary_channel_button(self):
|
||||
self.ensure_one()
|
||||
if self.use_opportunities:
|
||||
action = self.env['ir.actions.actions']._for_xml_id('crm.crm_case_form_view_salesteams_opportunity')
|
||||
rcontext = {
|
||||
'team': self,
|
||||
}
|
||||
action['help'] = self.env['ir.ui.view']._render_template('crm.crm_action_helper', values=rcontext)
|
||||
return action
|
||||
return super(Team,self).action_primary_channel_button()
|
||||
|
||||
def _graph_get_model(self):
|
||||
if self.use_opportunities:
|
||||
return 'crm.lead'
|
||||
return super(Team,self)._graph_get_model()
|
||||
|
||||
def _graph_date_column(self):
|
||||
if self.use_opportunities:
|
||||
return 'create_date'
|
||||
return super(Team,self)._graph_date_column()
|
||||
|
||||
def _graph_y_query(self):
|
||||
if self.use_opportunities:
|
||||
return 'count(*)'
|
||||
return super(Team,self)._graph_y_query()
|
||||
|
||||
def _extra_sql_conditions(self):
|
||||
if self.use_opportunities:
|
||||
return "AND type LIKE 'opportunity'"
|
||||
return super(Team,self)._extra_sql_conditions()
|
||||
|
||||
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 self.action_open_leads()
|
||||
return super().action_primary_channel_button()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from odoo import api, fields, models, _
|
|||
from odoo.exceptions import AccessError
|
||||
|
||||
|
||||
class Digest(models.Model):
|
||||
class DigestDigest(models.Model):
|
||||
_inherit = 'digest.digest'
|
||||
|
||||
kpi_crm_lead_created = fields.Boolean('New Leads')
|
||||
|
|
@ -16,31 +16,24 @@ class Digest(models.Model):
|
|||
def _compute_kpi_crm_lead_created_value(self):
|
||||
if not self.env.user.has_group('sales_team.group_sale_salesman'):
|
||||
raise AccessError(_("Do not have access, skip this data for user's digest email"))
|
||||
for record in self:
|
||||
start, end, company = record._get_kpi_compute_parameters()
|
||||
record.kpi_crm_lead_created_value = self.env['crm.lead'].search_count([
|
||||
('create_date', '>=', start),
|
||||
('create_date', '<', end),
|
||||
('company_id', '=', company.id)
|
||||
])
|
||||
|
||||
self._calculate_company_based_kpi('crm.lead', 'kpi_crm_lead_created_value')
|
||||
|
||||
def _compute_kpi_crm_opportunities_won_value(self):
|
||||
if not self.env.user.has_group('sales_team.group_sale_salesman'):
|
||||
raise AccessError(_("Do not have access, skip this data for user's digest email"))
|
||||
for record in self:
|
||||
start, end, company = record._get_kpi_compute_parameters()
|
||||
record.kpi_crm_opportunities_won_value = self.env['crm.lead'].search_count([
|
||||
('type', '=', 'opportunity'),
|
||||
('probability', '=', '100'),
|
||||
('date_closed', '>=', start),
|
||||
('date_closed', '<', end),
|
||||
('company_id', '=', company.id)
|
||||
])
|
||||
|
||||
self._calculate_company_based_kpi(
|
||||
'crm.lead',
|
||||
'kpi_crm_opportunities_won_value',
|
||||
date_field='date_closed',
|
||||
additional_domain=[('type', '=', 'opportunity'), ('probability', '=', '100')],
|
||||
)
|
||||
|
||||
def _compute_kpis_actions(self, company, user):
|
||||
res = super(Digest, self)._compute_kpis_actions(company, user)
|
||||
res['kpi_crm_lead_created'] = 'crm.crm_lead_action_pipeline&menu_id=%s' % self.env.ref('crm.crm_menu_root').id
|
||||
res['kpi_crm_opportunities_won'] = 'crm.crm_lead_action_pipeline&menu_id=%s' % self.env.ref('crm.crm_menu_root').id
|
||||
res = super()._compute_kpis_actions(company, user)
|
||||
res['kpi_crm_lead_created'] = 'crm.crm_lead_action_pipeline?menu_id=%s' % self.env.ref('crm.crm_menu_root').id
|
||||
res['kpi_crm_opportunities_won'] = 'crm.crm_lead_action_pipeline?menu_id=%s' % self.env.ref('crm.crm_menu_root').id
|
||||
if user.has_group('crm.group_use_lead'):
|
||||
res['kpi_crm_lead_created'] = 'crm.crm_lead_all_leads&menu_id=%s' % self.env.ref('crm.crm_menu_root').id
|
||||
res['kpi_crm_lead_created'] = 'crm.crm_lead_all_leads?menu_id=%s' % self.env.ref('crm.crm_menu_root').id
|
||||
return res
|
||||
|
|
|
|||
|
|
@ -5,28 +5,28 @@ from odoo import api, models
|
|||
from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
|
||||
|
||||
|
||||
class IrConfigParameter(models.Model):
|
||||
class IrConfig_Parameter(models.Model):
|
||||
_inherit = 'ir.config_parameter'
|
||||
|
||||
def write(self, vals):
|
||||
result = super(IrConfigParameter, self).write(vals)
|
||||
result = super().write(vals)
|
||||
if any(record.key == "crm.pls_fields" for record in self):
|
||||
self.env.flush_all()
|
||||
self.env.registry.setup_models(self.env.cr)
|
||||
self.env.registry._setup_models__(self.env.cr, ['crm.lead'])
|
||||
return result
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super(IrConfigParameter, self).create(vals_list)
|
||||
records = super().create(vals_list)
|
||||
if any(record.key == "crm.pls_fields" for record in records):
|
||||
self.env.flush_all()
|
||||
self.env.registry.setup_models(self.env.cr)
|
||||
self.env.registry._setup_models__(self.env.cr, ['crm.lead'])
|
||||
return records
|
||||
|
||||
def unlink(self):
|
||||
pls_emptied = any(record.key == "crm.pls_fields" for record in self)
|
||||
result = super(IrConfigParameter, self).unlink()
|
||||
if pls_emptied and not self._context.get(MODULE_UNINSTALL_FLAG):
|
||||
result = super().unlink()
|
||||
if pls_emptied and not self.env.context.get(MODULE_UNINSTALL_FLAG):
|
||||
self.env.flush_all()
|
||||
self.env.registry.setup_models(self.env.cr)
|
||||
self.env.registry._setup_models__(self.env.cr, ['crm.lead'])
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from datetime import timedelta
|
|||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, exceptions, fields, models, _
|
||||
from odoo.tools import format_list
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
|
|
@ -14,6 +15,7 @@ class ResConfigSettings(models.TransientModel):
|
|||
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')
|
||||
module_partnership = fields.Boolean("Membership / Partnership")
|
||||
# Lead assignment
|
||||
crm_use_auto_assignment = fields.Boolean(
|
||||
string='Rule-Based Assignment', config_parameter='crm.lead.auto.assignment')
|
||||
|
|
@ -61,7 +63,8 @@ class ResConfigSettings(models.TransientModel):
|
|||
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_type = 'days'
|
||||
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')
|
||||
|
|
@ -125,16 +128,16 @@ class ResConfigSettings(models.TransientModel):
|
|||
for setting in self:
|
||||
if setting.predictive_lead_scoring_fields:
|
||||
field_names = [_('Stage')] + [field.name for field in setting.predictive_lead_scoring_fields]
|
||||
setting.predictive_lead_scoring_field_labels = _('%s and %s', ', '.join(field_names[:-1]), field_names[-1])
|
||||
setting.predictive_lead_scoring_field_labels = format_list(self.env, field_names)
|
||||
else:
|
||||
setting.predictive_lead_scoring_field_labels = _('Stage')
|
||||
|
||||
def set_values(self):
|
||||
group_use_lead_id = self.env['ir.model.data']._xmlid_to_res_id('crm.group_use_lead')
|
||||
has_group_lead_before = group_use_lead_id in self.env.user.groups_id.ids
|
||||
has_group_lead_before = group_use_lead_id in self.env.user.all_group_ids.ids
|
||||
super(ResConfigSettings, self).set_values()
|
||||
# update use leads / opportunities setting on all teams according to settings update
|
||||
has_group_lead_after = group_use_lead_id in self.env.user.groups_id.ids
|
||||
has_group_lead_after = group_use_lead_id in self.env.user.all_group_ids.ids
|
||||
if has_group_lead_before != has_group_lead_after:
|
||||
teams = self.env['crm.team'].search([])
|
||||
teams.filtered('use_opportunities').use_leads = has_group_lead_after
|
||||
|
|
@ -167,4 +170,4 @@ class ResConfigSettings(models.TransientModel):
|
|||
|
||||
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)
|
||||
return self.env['crm.team'].search([('assignment_optout', '=', False)]).action_assign_leads()
|
||||
|
|
|
|||
|
|
@ -1,77 +1,62 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.osv import expression
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
_name = 'res.partner'
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
team_id = fields.Many2one(
|
||||
'crm.team', string='Sales Team',
|
||||
compute='_compute_team_id',
|
||||
precompute=True, # avoid queries post-create
|
||||
ondelete='set null', readonly=False, store=True)
|
||||
opportunity_ids = fields.One2many('crm.lead', 'partner_id', string='Opportunities', domain=[('type', '=', 'opportunity')])
|
||||
opportunity_count = fields.Integer("Opportunity", compute='_compute_opportunity_count')
|
||||
opportunity_count = fields.Integer(
|
||||
string="Opportunity Count",
|
||||
groups='sales_team.group_sale_salesman',
|
||||
compute='_compute_opportunity_count',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
rec = super(Partner, self).default_get(fields)
|
||||
active_model = self.env.context.get('active_model')
|
||||
if active_model == 'crm.lead' and len(self.env.context.get('active_ids', [])) <= 1:
|
||||
lead = self.env[active_model].browse(self.env.context.get('active_id')).exists()
|
||||
if lead:
|
||||
rec.update(
|
||||
phone=lead.phone,
|
||||
mobile=lead.mobile,
|
||||
function=lead.function,
|
||||
title=lead.title.id,
|
||||
website=lead.website,
|
||||
street=lead.street,
|
||||
street2=lead.street2,
|
||||
city=lead.city,
|
||||
state_id=lead.state_id.id,
|
||||
country_id=lead.country_id.id,
|
||||
zip=lead.zip,
|
||||
)
|
||||
return rec
|
||||
|
||||
@api.depends('parent_id')
|
||||
def _compute_team_id(self):
|
||||
for partner in self.filtered(lambda partner: not partner.team_id and partner.company_type == 'person' and partner.parent_id.team_id):
|
||||
partner.team_id = partner.parent_id.team_id
|
||||
|
||||
def _compute_opportunity_count(self):
|
||||
# retrieve all children partners and prefetch 'parent_id' on them
|
||||
all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
|
||||
all_partners.read(['parent_id'])
|
||||
|
||||
opportunity_data = self.env['crm.lead'].with_context(active_test=False)._read_group(
|
||||
domain=[('partner_id', 'in', all_partners.ids)],
|
||||
fields=['partner_id'], groupby=['partner_id']
|
||||
def _fetch_children_partners_for_hierarchy(self):
|
||||
# retrieve all children partners and prefetch 'parent_id' on them, saving
|
||||
# queries for recursive parent_id browse
|
||||
return self.with_context(active_test=False).search_fetch(
|
||||
[('id', 'child_of', self.ids)], ['parent_id'],
|
||||
)
|
||||
|
||||
def _get_contact_opportunities_domain(self):
|
||||
return [('partner_id', 'in', self._fetch_children_partners_for_hierarchy().ids)]
|
||||
|
||||
def _compute_opportunity_count(self):
|
||||
self.opportunity_count = 0
|
||||
for group in opportunity_data:
|
||||
partner = self.browse(group['partner_id'][0])
|
||||
if not self.env.user.has_group('sales_team.group_sale_salesman'):
|
||||
return
|
||||
opportunity_data = self.env['crm.lead'].with_context(active_test=False)._read_group(
|
||||
domain=self._get_contact_opportunities_domain(),
|
||||
groupby=['partner_id'], aggregates=['__count']
|
||||
)
|
||||
current_pids = set(self._ids)
|
||||
for partner, count in opportunity_data:
|
||||
while partner:
|
||||
if partner in self:
|
||||
partner.opportunity_count += group['partner_id_count']
|
||||
if partner.id in current_pids:
|
||||
partner.opportunity_count += count
|
||||
partner = partner.parent_id
|
||||
|
||||
def _compute_application_statistics_hook(self):
|
||||
data_list = super()._compute_application_statistics_hook()
|
||||
if not self.env.user.has_group('sales_team.group_sale_salesman'):
|
||||
return data_list
|
||||
for partner in self.filtered('opportunity_count'):
|
||||
data_list[partner.id].append(
|
||||
{'iconClass': 'fa-star', 'value': partner.opportunity_count, 'label': _('Opportunities'), 'tagClass': 'o_tag_color_8'}
|
||||
)
|
||||
return data_list
|
||||
|
||||
def action_view_opportunity(self):
|
||||
'''
|
||||
This function returns an action that displays the opportunities from partner.
|
||||
'''
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('crm.crm_lead_opportunities')
|
||||
action['context'] = {}
|
||||
if self.is_company:
|
||||
action['domain'] = [('partner_id.commercial_partner_id', '=', self.id)]
|
||||
else:
|
||||
action['domain'] = [('partner_id', '=', self.id)]
|
||||
action['domain'] = expression.AND([action['domain'], [('active', 'in', [True, False])]])
|
||||
action['context'] = {
|
||||
'search_default_filter_won': 1,
|
||||
'search_default_filter_ongoing': 1,
|
||||
'search_default_filter_lost': 1,
|
||||
'active_test': False,
|
||||
}
|
||||
# we want the list view first
|
||||
action['views'] = sorted(action['views'], key=lambda view: view[1] != 'list')
|
||||
action['domain'] = self._get_contact_opportunities_domain()
|
||||
return action
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import _, api, models
|
||||
|
||||
|
||||
class Users(models.Model):
|
||||
_inherit = 'res.users'
|
||||
class ResUsers(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
target_sales_won = fields.Integer('Won in Opportunities Target')
|
||||
target_sales_done = fields.Integer('Activities Done Target')
|
||||
@api.depends_context(
|
||||
'crm_formatted_display_name_team',
|
||||
'formatted_display_name')
|
||||
def _compute_display_name(self):
|
||||
super()._compute_display_name()
|
||||
formatted_display_name = self.env.context.get('formatted_display_name')
|
||||
team_id = self.env.context.get('crm_formatted_display_name_team', 0)
|
||||
if formatted_display_name and team_id:
|
||||
leader_id = self.env['crm.team'].browse(team_id).user_id
|
||||
for user in self.filtered(lambda u: u == leader_id):
|
||||
user.display_name += " --%s--" % _("(Team Leader)")
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@ class UtmCampaign(models.Model):
|
|||
def _compute_crm_lead_count(self):
|
||||
lead_data = self.env['crm.lead'].with_context(active_test=False)._read_group([
|
||||
('campaign_id', 'in', self.ids)],
|
||||
['campaign_id'], ['campaign_id'])
|
||||
mapped_data = {datum['campaign_id'][0]: datum['campaign_id_count'] for datum in lead_data}
|
||||
['campaign_id'], ['__count'])
|
||||
mapped_data = {campaign.id: count for campaign, count in lead_data}
|
||||
for campaign in self:
|
||||
campaign.crm_lead_count = mapped_data.get(campaign.id, 0)
|
||||
|
||||
def action_redirect_to_leads_opportunities(self):
|
||||
view = 'crm.crm_lead_all_leads' if self.use_leads else 'crm.crm_lead_opportunities'
|
||||
action = self.env['ir.actions.act_window']._for_xml_id(view)
|
||||
action['view_mode'] = 'tree,kanban,graph,pivot,form,calendar'
|
||||
action['view_mode'] = 'list,kanban,graph,pivot,form,calendar'
|
||||
action['domain'] = [('campaign_id', 'in', self.ids)]
|
||||
action['context'] = {'active_test': False, 'create': False}
|
||||
return action
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue