GLOBAl PURPOSE Ability to have salesmen belonging to several sales team is a core requirement of CRM. It is therefore moved from website_crm_score to crm along with cleaning and behavior improvement. Automatic lead assignment is also moved and cleaned. SPECIFICATIONS Compute default team id for sales related documents. Note that this method is not called by default_get as it takes some additional parameters and is meant to be called by other default methods. Heuristic (when multiple match: take first sequence ordered) 1- any of my teams (member OR responsible) matching domain 2- any of my teams (member OR responsible) 3- default from context 4- any team matching my company and domain 5- any team matching my company Note: ResPartner.team_id field is explicitly not taken into account. We think this field causes a lot of noises compared to its added value. Think notably: team not in responsible teams, team company not matching responsible or lead company, asked domain not matching, ... We also improve various places where sale team is computed on sales documents. Purpose is to try to use same heuristic as much as possible in various team_id fields. LINKS Task ID-2086889 (main task) Task ID-2357969 (scoring migration task) Community PR odoo/odoo#48422 Enterprise PR odoo/enterprise#499 Upgrade PR odoo/upgrade#996
366 lines
16 KiB
Python
366 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import json
|
|
|
|
from babel.dates import format_date
|
|
from datetime import date
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
from odoo.release import version
|
|
|
|
|
|
class CrmTeam(models.Model):
|
|
_name = "crm.team"
|
|
_inherit = ['mail.thread']
|
|
_description = "Sales Team"
|
|
_order = "sequence"
|
|
_check_company_auto = True
|
|
|
|
def _get_default_team_id(self, user_id=None, domain=None):
|
|
""" Compute default team id for sales related documents. Note that this
|
|
method is not called by default_get as it takes some additional
|
|
parameters and is meant to be called by other default methods.
|
|
|
|
Heuristic (when multiple match: take first sequence ordered)
|
|
|
|
1- any of my teams (member OR responsible) matching domain
|
|
2- any of my teams (member OR responsible)
|
|
3- default from context
|
|
4- any team matching my company and domain
|
|
5- any team matching my company
|
|
|
|
Note: ResPartner.team_id field is explicitly not taken into account. We
|
|
think this field causes a lot of noises compared to its added value.
|
|
Think notably: team not in responsible teams, team company not matching
|
|
responsible or lead company, asked domain not matching, ...
|
|
|
|
:param user_id: salesperson to target, fallback on env.uid;
|
|
:domain: optional domain to filter teams (like use_lead = True);
|
|
"""
|
|
if user_id is None:
|
|
user = self.env.user
|
|
else:
|
|
user = self.env['res.users'].sudo().browse(user_id)
|
|
valid_cids = [False] + user.company_ids.ids
|
|
|
|
# 1- find in user memberships - note that if current user in C1 searches
|
|
# for team belonging to a user in C1/C2 -> only results for C1 will be returned
|
|
team = self.env['crm.team']
|
|
teams = self.env['crm.team'].search([
|
|
('company_id', 'in', valid_cids),
|
|
'|', ('user_id', '=', user.id), ('member_ids', 'in', [user.id]),
|
|
])
|
|
if teams and domain:
|
|
team = teams.filtered_domain(domain)[:1]
|
|
# 2- any of my teams
|
|
if not team:
|
|
team = teams[:1]
|
|
|
|
# 3- default: context
|
|
if not team and 'default_team_id' in self.env.context:
|
|
team = self.env['crm.team'].browse(self.env.context.get('default_team_id'))
|
|
|
|
# 4- default: first one matching domain, then first one
|
|
if not team:
|
|
teams = self.env['crm.team'].search([('company_id', 'in', valid_cids)])
|
|
if teams and domain:
|
|
team = teams.filtered_domain(domain)[:1]
|
|
if not team:
|
|
team = teams[:1]
|
|
|
|
return team
|
|
|
|
def _get_default_favorite_user_ids(self):
|
|
return [(6, 0, [self.env.uid])]
|
|
|
|
# description
|
|
name = fields.Char('Sales Team', required=True, translate=True)
|
|
sequence = fields.Integer('Sequence', default=10)
|
|
active = fields.Boolean(default=True, help="If the active field is set to false, it will allow you to hide the Sales Team without removing it.")
|
|
company_id = fields.Many2one(
|
|
'res.company', string='Company', index=True,
|
|
default=lambda self: self.env.company)
|
|
currency_id = fields.Many2one(
|
|
"res.currency", string="Currency",
|
|
related='company_id.currency_id', readonly=True)
|
|
user_id = fields.Many2one('res.users', string='Team Leader', check_company=True)
|
|
# memberships
|
|
is_membership_multi = fields.Boolean(
|
|
'Multiple Memberships Allowed', compute='_compute_is_membership_multi',
|
|
help='If True, users may belong to several sales teams. Otherwise membership is limited to a single sales team.')
|
|
member_ids = fields.Many2many(
|
|
'res.users', string='Salespersons',
|
|
domain="['&', ('share', '=', False), ('company_ids', 'in', member_company_ids)]",
|
|
compute='_compute_member_ids', inverse='_inverse_member_ids', search='_search_member_ids',
|
|
help="Users assigned to this team.")
|
|
member_company_ids = fields.Many2many(
|
|
'res.company', compute='_compute_member_company_ids',
|
|
help='UX: Limit to team company or all if no company')
|
|
member_warning = fields.Text('Membership Issue Warning', compute='_compute_member_warning')
|
|
crm_team_member_ids = fields.One2many(
|
|
'crm.team.member', 'crm_team_id', string='Sales Team Members',
|
|
help="Add members to automatically assign their documents to this sales team.")
|
|
crm_team_member_all_ids = fields.One2many(
|
|
'crm.team.member', 'crm_team_id', string='Sales Team Members (incl. inactive)',
|
|
context={'active_test': False})
|
|
# UX options
|
|
color = fields.Integer(string='Color Index', help="The color of the channel")
|
|
favorite_user_ids = fields.Many2many(
|
|
'res.users', 'team_favorite_user_rel', 'team_id', 'user_id',
|
|
string='Favorite Members', default=_get_default_favorite_user_ids)
|
|
is_favorite = fields.Boolean(
|
|
string='Show on dashboard', compute='_compute_is_favorite', inverse='_inverse_is_favorite',
|
|
help="Favorite teams to display them in the dashboard and access them easily.")
|
|
dashboard_button_name = fields.Char(string="Dashboard Button", compute='_compute_dashboard_button_name')
|
|
dashboard_graph_data = fields.Text(compute='_compute_dashboard_graph')
|
|
|
|
@api.depends('sequence') # TDE FIXME: force compute in new mode
|
|
def _compute_is_membership_multi(self):
|
|
multi_enabled = self.env['ir.config_parameter'].sudo().get_param('sales_team.membership_multi', False)
|
|
self.is_membership_multi = multi_enabled
|
|
|
|
@api.depends('crm_team_member_ids.active')
|
|
def _compute_member_ids(self):
|
|
for team in self:
|
|
team.member_ids = team.crm_team_member_ids.user_id
|
|
|
|
def _inverse_member_ids(self):
|
|
for team in self:
|
|
# pre-save value to avoid having _compute_member_ids interfering
|
|
# while building membership status
|
|
memberships = team.crm_team_member_ids
|
|
users_current = team.member_ids
|
|
users_new = users_current - memberships.user_id
|
|
|
|
# add missing memberships
|
|
self.env['crm.team.member'].create([{'crm_team_id': team.id, 'user_id': user.id} for user in users_new])
|
|
|
|
# activate or deactivate other memberships depending on members
|
|
for membership in memberships:
|
|
membership.active = membership.user_id in users_current
|
|
|
|
@api.depends('is_membership_multi', 'member_ids')
|
|
def _compute_member_warning(self):
|
|
""" Display a warning message to warn user they are about to archive
|
|
other memberships. Only valid in mono-membership mode and take into
|
|
account only active memberships as we may keep several archived
|
|
memberships. """
|
|
self.member_warning = False
|
|
if all(team.is_membership_multi for team in self):
|
|
return
|
|
# done in a loop, but to be used in form view only -> not optimized
|
|
for team in self:
|
|
member_warning = False
|
|
other_memberships = self.env['crm.team.member'].search([
|
|
('crm_team_id', '!=', team.id if team.ids else False), # handle NewID
|
|
('user_id', 'in', team.member_ids.ids)
|
|
])
|
|
if other_memberships and len(other_memberships) == 1:
|
|
member_warning = _("Adding %(user_name)s in this team would remove him/her from its current team %(team_name)s.",
|
|
user_name=other_memberships.user_id.name,
|
|
team_name=other_memberships.crm_team_id.name
|
|
)
|
|
elif other_memberships:
|
|
member_warning = _("Adding %(user_names)s in this team would remove them from their current teams (%(team_names)s).",
|
|
user_names=", ".join(other_memberships.mapped('user_id.name')),
|
|
team_names=", ".join(other_memberships.mapped('crm_team_id.name'))
|
|
)
|
|
if member_warning:
|
|
team.member_warning = member_warning + " " + _("To add a Salesperson into multiple Teams, activate the Multi-Team option in settings.")
|
|
|
|
def _search_member_ids(self, operator, value):
|
|
return [('crm_team_member_ids.user_id', operator, value)]
|
|
|
|
@api.depends('company_id')
|
|
def _compute_member_company_ids(self):
|
|
""" Available companies for members. Either team company if set, either
|
|
any company if not set on team. """
|
|
all_companies = self.env['res.company'].search([])
|
|
for team in self:
|
|
team.member_company_ids = team.company_id or all_companies
|
|
|
|
def _compute_is_favorite(self):
|
|
for team in self:
|
|
team.is_favorite = self.env.user in team.favorite_user_ids
|
|
|
|
def _inverse_is_favorite(self):
|
|
sudoed_self = self.sudo()
|
|
to_fav = sudoed_self.filtered(lambda team: self.env.user not in team.favorite_user_ids)
|
|
to_fav.write({'favorite_user_ids': [(4, self.env.uid)]})
|
|
(sudoed_self - to_fav).write({'favorite_user_ids': [(3, self.env.uid)]})
|
|
return True
|
|
|
|
def _compute_dashboard_button_name(self):
|
|
""" Sets the adequate dashboard button name depending on the Sales Team's options
|
|
"""
|
|
for team in self:
|
|
team.dashboard_button_name = _("Big Pretty Button :)") # placeholder
|
|
|
|
def _compute_dashboard_graph(self):
|
|
for team in self:
|
|
team.dashboard_graph_data = json.dumps(team._get_dashboard_graph_data())
|
|
|
|
# ------------------------------------------------------------
|
|
# CRUD
|
|
# ------------------------------------------------------------
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
teams = super(CrmTeam, self.with_context(mail_create_nosubscribe=True)).create(vals_list)
|
|
teams.filtered(lambda t: t.member_ids)._add_members_to_favorites()
|
|
return teams
|
|
|
|
def write(self, values):
|
|
res = super(CrmTeam, self).write(values)
|
|
# manually launch company sanity check
|
|
if values.get('company_id'):
|
|
self.crm_team_member_ids._check_company(fnames=['crm_team_id'])
|
|
|
|
if values.get('member_ids'):
|
|
self._add_members_to_favorites()
|
|
return res
|
|
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_except_default(self):
|
|
default_teams = [
|
|
self.env.ref('sales_team.salesteam_website_sales'),
|
|
self.env.ref('sales_team.pos_sales_team'),
|
|
self.env.ref('sales_team.ebay_sales_team')
|
|
]
|
|
for team in self:
|
|
if team in default_teams:
|
|
raise UserError(_('Cannot delete default team "%s"', team.name))
|
|
|
|
# ------------------------------------------------------------
|
|
# ACTIONS
|
|
# ------------------------------------------------------------
|
|
|
|
def action_primary_channel_button(self):
|
|
""" Skeleton function to be overloaded It will return the adequate action
|
|
depending on the Sales Team's options. """
|
|
return False
|
|
|
|
# ------------------------------------------------------------
|
|
# TOOLS
|
|
# ------------------------------------------------------------
|
|
|
|
def _add_members_to_favorites(self):
|
|
for team in self:
|
|
team.favorite_user_ids = [(4, member.id) for member in team.member_ids]
|
|
|
|
# ------------------------------------------------------------
|
|
# GRAPH
|
|
# ------------------------------------------------------------
|
|
|
|
def _graph_get_model(self):
|
|
""" skeleton function defined here because it'll be called by crm and/or sale
|
|
"""
|
|
raise UserError(_('Undefined graph model for Sales Team: %s', self.name))
|
|
|
|
def _graph_get_dates(self, today):
|
|
""" return a coherent start and end date for the dashboard graph covering a month period grouped by week.
|
|
"""
|
|
start_date = today - relativedelta(months=1)
|
|
# we take the start of the following week if we group by week
|
|
# (to avoid having twice the same week from different month)
|
|
start_date += relativedelta(days=8 - start_date.isocalendar()[2])
|
|
return [start_date, today]
|
|
|
|
def _graph_date_column(self):
|
|
return 'create_date'
|
|
|
|
def _graph_x_query(self):
|
|
return 'EXTRACT(WEEK FROM %s)' % self._graph_date_column()
|
|
|
|
def _graph_y_query(self):
|
|
raise UserError(_('Undefined graph model for Sales Team: %s', self.name))
|
|
|
|
def _extra_sql_conditions(self):
|
|
return ''
|
|
|
|
def _graph_title_and_key(self):
|
|
""" Returns an array containing the appropriate graph title and key respectively.
|
|
|
|
The key is for lineCharts, to have the on-hover label.
|
|
"""
|
|
return ['', '']
|
|
|
|
def _graph_data(self, start_date, end_date):
|
|
""" return format should be an iterable of dicts that contain {'x_value': ..., 'y_value': ...}
|
|
x_values should be weeks.
|
|
y_values are floats.
|
|
"""
|
|
query = """SELECT %(x_query)s as x_value, %(y_query)s as y_value
|
|
FROM %(table)s
|
|
WHERE team_id = %(team_id)s
|
|
AND DATE(%(date_column)s) >= %(start_date)s
|
|
AND DATE(%(date_column)s) <= %(end_date)s
|
|
%(extra_conditions)s
|
|
GROUP BY x_value;"""
|
|
|
|
# apply rules
|
|
dashboard_graph_model = self._graph_get_model()
|
|
GraphModel = self.env[dashboard_graph_model]
|
|
graph_table = GraphModel._table
|
|
extra_conditions = self._extra_sql_conditions()
|
|
where_query = GraphModel._where_calc([])
|
|
GraphModel._apply_ir_rules(where_query, 'read')
|
|
from_clause, where_clause, where_clause_params = where_query.get_sql()
|
|
if where_clause:
|
|
extra_conditions += " AND " + where_clause
|
|
|
|
query = query % {
|
|
'x_query': self._graph_x_query(),
|
|
'y_query': self._graph_y_query(),
|
|
'table': graph_table,
|
|
'team_id': "%s",
|
|
'date_column': self._graph_date_column(),
|
|
'start_date': "%s",
|
|
'end_date': "%s",
|
|
'extra_conditions': extra_conditions
|
|
}
|
|
|
|
self._cr.execute(query, [self.id, start_date, end_date] + where_clause_params)
|
|
return self.env.cr.dictfetchall()
|
|
|
|
def _get_dashboard_graph_data(self):
|
|
def get_week_name(start_date, locale):
|
|
""" Generates a week name (string) from a datetime according to the locale:
|
|
E.g.: locale start_date (datetime) return string
|
|
"en_US" November 16th "16-22 Nov"
|
|
"en_US" December 28th "28 Dec-3 Jan"
|
|
"""
|
|
if (start_date + relativedelta(days=6)).month == start_date.month:
|
|
short_name_from = format_date(start_date, 'd', locale=locale)
|
|
else:
|
|
short_name_from = format_date(start_date, 'd MMM', locale=locale)
|
|
short_name_to = format_date(start_date + relativedelta(days=6), 'd MMM', locale=locale)
|
|
return short_name_from + '-' + short_name_to
|
|
|
|
self.ensure_one()
|
|
values = []
|
|
today = fields.Date.from_string(fields.Date.context_today(self))
|
|
start_date, end_date = self._graph_get_dates(today)
|
|
graph_data = self._graph_data(start_date, end_date)
|
|
x_field = 'label'
|
|
y_field = 'value'
|
|
|
|
# generate all required x_fields and update the y_values where we have data for them
|
|
locale = self._context.get('lang') or 'en_US'
|
|
|
|
weeks_in_start_year = int(date(start_date.year, 12, 28).isocalendar()[1]) # This date is always in the last week of ISO years
|
|
for week in range(0, (end_date.isocalendar()[1] - start_date.isocalendar()[1]) % weeks_in_start_year + 1):
|
|
short_name = get_week_name(start_date + relativedelta(days=7 * week), locale)
|
|
values.append({x_field: short_name, y_field: 0})
|
|
|
|
for data_item in graph_data:
|
|
index = int((data_item.get('x_value') - start_date.isocalendar()[1]) % weeks_in_start_year)
|
|
values[index][y_field] = data_item.get('y_value')
|
|
|
|
[graph_title, graph_key] = self._graph_title_and_key()
|
|
color = '#875A7B' if '+e' in version else '#7c7bad'
|
|
return [{'values': values, 'area': True, 'title': graph_title, 'key': graph_key, 'color': color}]
|