[MERGE] crm_*, sales_team: enhance sales team memberships

RATIONALE

For historic as well as internal reasons many CRM features were added into a
module called "website_crm_score". This module was mainly developed to manage
Odoo's prospects based on our own processes and rules. It included

  * automatic lead assignment on teams, based on a score (lead scoring) and
    team- and salespersons- specific domains;
  * support of web pages (page views);
  * support of multi sales team membership for salespersons;

This task aims at dismantling this module to make sure those valuable features
can be used by all CRM users

  * lead assignment is moved into CRM;
  * lead scoring is replaced by PLS and automatic probabilities computation;
  * page views is now managed through visitors;
  * multi sales team memberships is moved into CRM;

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: SALES TEAMS MEMBERSHIP

Move sales team membership model from website_crm_score (enterprise) directly
into sales_team. It allows to have the base requirements to correctly handle
mono and multi sales team for salesperson.

Membership is modeled using a decorated many2many relationship. Members of
a sales team are res.users. Subscriptions are crm.team.member linking a team
and an user. It is a rename of team.user model coming from website_crm_score
as well as some other relational fields renaming.

Part of old team.user model is moved directly to sales team, part of it to
crm module (notably assignment limits). Its support will be improved in the
upcoming commits. Assignment related fields will be cleaned and used when
moving assignment directly into CRM.

SPECIFICATIONS: MONO / MULTI TEAMS

Purpose of this commit is to allow to use either mono salesteam mode, either
multi salesteams mode. Basic CRM use mono membership: a salesperson belongs
to a single sales team. It is however possible to choose to work using multiple
memberships. This is configured through a configuration parameter available in
CRM configuration settings.

Mono salesteam behavior
  * a given user can be member of a single sales team at a time;
  * when moving an user from team to team, its existing memberships are
    archived to keep history of what has been done within this team;
  * display an alert when creating a new team member that would archive
    existing memberships;
  * displayed members on team form view are directly res.users records to
    avoid displaying unnecessary layer when using a simple CRM;

Multi salesteam behavior
  * a given user can be member of several sales team at a time;
  * unicity contraint (user_id, crm_team_id) is still enforced;
  * displayed members on team form view are crm.team.member records as
    advanced configuration can be done directly at that level;

In all cases when adding a new member check for archived membership before
creating it. If an archived version of the member exists it is unlinked in
order to avoid breaking the unicity constraint.

Archived memberships can be managed in its dedicated menu for advanced sales
team use.

SPECIFICATIONS: COMPANY-BASED DOMAINS

Improve support of multi company. Sales team can either be linked to a given
company, either be cross companies (company_id = False). Currently it is easy
to have issues while trying to add users from a companyA to a team belonging
to companyB.

When using mono membership mode
  * members are res.users records;
  * a domain is added on users to belong either to the team company, either
    to all available teams if team has no company;

When using multi membership mode
  * members are crm.team.member records;
  * a domain is added on user_id field of membership records so that they
    belong either to the team company, either to all available teams if team
    has no company;

Those domains are implemented using a computed field it is not directly
translatable to a domain. Domain is added directly on field at model level
and include the "avoid shared users" domain leaf.

SPECIFICATIONS: CHECK_COMPANY

``check_company`` attribute is set on team member allowing to automatically
detect badly configured memberships. Check company is activated on user_id
and team_id fields of crm.team.member model to ensure they match.

SPECIFICATIONS: LEAD ASSIGNMENT

Move sales team automatic lead assignment from website_crm_score (enterprise)
directly into sales_team. Some cleaning and renaming is performed while moving
the code as it is old fashioned.

Scoring feature is removed completely from lead assignment. Indeed we consider
PLS is a better feature and provides more accurate results.

Minimal scoring specific field on team is removed. Indeed it can also be
integrated directly into domains linked to a sales team as this is a field
directly available on lead using ``probability``. It allows to reduce fields
of team model and move all logic in the team's domain itself.

Overall behavior as been kept as close as possible from the original one.
Purpose is to avoid too much undesired changes between versions.

In this commit we also add tests for automatic lead assignment.

ASSIGNMENT PROCESS DESCRIPTION

1- LEAD TO TEAM ALLOCATION

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 allocate
leads within teams.

Heuristic of this method is the following:
  * first we randomize all teams;
  * then for each team

    * find unassigned leads, 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;
      * if set, a delay after creation can be applied (see BUNDLE_HOURS_DELAY)
        parameter explanations here below;

    * keep only leads matching the team's assignment domain (empty means
      everything);
    * assign maximum BUNDLE_SIZE leads to the team, then move to the next team.
      This is done to ensure every team will have leads enough to fill its
      capacity based on its domain;
    * when setting a team on leads, leads belonging to the current batch
      are also merged. Purpose is to clean database and avoid assigning
      duplicates to same or different teams;

  * for all teams that still have capacity (aka: a search on unassigned
    available leads with team domain still give results), do another
    assignment round. Each round teams are randomized so that team order
    is not always the same;

Note that leads are assigned in batch meaning a team could receive leads that
could better fit another team. However this heuristics is based on hypothesis
that team domains do not overlap. Indeed if a company has several teams they
will probably target separate market segments: country-based, customer type or
size, ... Having several teams using same assignment domain could lead to less
fairness in assignment process but this should not be the target use case of this
heuristic.

Leads are allocated by batch. This can be configured using a config parameter.
Batch size depends on cron frequency, lead pipeline size and members assignment
maximum. Finding an optimal heuristic for this parameter is not easy as it
depends on internal processes and organization. Higher batch size leads to
better performances when running automatic assignment. It can also give unfair
results if teams domain overlap or if pipeline is not big enough to fill all
teams capacity.

2- LEAD TO MEMBER ASSIGN

Main processing method to assign leads to sales team members. It also converts
them into opportunities. 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
    receive 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``
    * 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

Assign process 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.

SPECIFICATIONS: REMOVE WEBSITE CRM SCORE

Remove website_crm_score module as its core business is not moved. Scoring
itself is not used anymore as it is replaced by automatic lead computation
(PLS) and all other features are moved to core CRM application.

SPECIFICATIONS: IMPROVE ASSIGN PROCESS

Various commits are added to improve assign process. See sub commits for
more details. Global behavior is not changed but details are improved like

  * include leads with probability being 100 but still not won
  * ensure automatic assignment works out of the box
  * continue filling salespersons pipe when they win leads
  * improve team opt-out from lead automatic assignment
  * limit allocation to teams when running automatic assign
  * log manual assign and improve feedback message
  * simplify evaluation of assignment domains

LINKS

Task ID-2086889 (main task)
Task ID-2357969 (scoring migration task)
Community PR odoo/odoo#48422
Enterprise PR odoo/enterprise#9499
Upgrade PR odoo/upgrade#996

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2021-01-21 17:40:50 +01:00
committed by GitHub
53 changed files with 2825 additions and 394 deletions
+5
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'CRM',
'version': '1.4',
@@ -18,6 +19,7 @@
'fetchmail',
'utm',
'web_tour',
'web_kanban_gauge',
'contacts',
'digest',
'phone_validation',
@@ -31,6 +33,7 @@
'data/crm_stage_data.xml',
'data/crm_team_data.xml',
'data/digest_data.xml',
'data/ir_cron_data.xml',
'data/mail_data.xml',
'data/crm_recurring_plan_data.xml',
@@ -46,6 +49,7 @@
'views/crm_lost_reason_views.xml',
'views/crm_stage_views.xml',
'views/crm_lead_views.xml',
'views/crm_team_member_views.xml',
'views/digest_views.xml',
'views/mail_activity_views.xml',
'views/res_config_settings_views.xml',
@@ -58,6 +62,7 @@
'demo': [
'data/crm_team_demo.xml',
'data/mail_template_demo.xml',
'data/crm_team_member_demo.xml',
'data/mail_activity_demo.xml',
'data/crm_lead_demo.xml',
],
+265 -240
View File
@@ -86,7 +86,7 @@
<field name="phone">+1 555 754 3010</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor4'), ref('sales_team.categ_oppor5')])]"/>
<field name="priority">2</field>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="user_id" ref="base.user_admin"/>
<field name="date_open" eval="(DateTime.today() - relativedelta(months=1)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="stage_lead1"/>
@@ -108,7 +108,7 @@
<field name="street">United Street 68</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor5')])]"/>
<field name="priority">2</field>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="user_id" ref="base.user_admin"/>
<field name="date_open" eval="(DateTime.today() - relativedelta(months=1)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="stage_lead1"/>
@@ -132,7 +132,7 @@
<field name="phone">+1 813 494 5005</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">2</field>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="user_id" ref="base.user_admin"/>
<field name="stage_id" ref="stage_lead1"/>
<field name="description"></field>
@@ -171,7 +171,7 @@ Contact: +1 813 494 5005</p>]]></field>
<field name="zip">00118</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor3'), ref('sales_team.categ_oppor4')])]"/>
<field name="priority">2</field>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="user_id" ref="base.user_admin"/>
<field name="stage_id" ref="stage_lead1"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_services"/>
@@ -249,7 +249,7 @@ ESM Expert<br />]]></field>
<field name="zip">OX1 1RQ</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor4'), ref('sales_team.categ_oppor7')])]"/>
<field name="priority">2</field>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="user_id" ref="base.user_demo"/>
<field name="stage_id" ref="stage_lead1"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
@@ -305,7 +305,7 @@ Andrew</p>]]></field>
<field name="zip">1300</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor7')])]"/>
<field name="priority">2</field>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="user_id" ref="base.user_admin"/>
<field name="stage_id" ref="stage_lead1"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
@@ -604,7 +604,7 @@ Andrew</p>]]></field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=1)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="user_id" ref="base.user_demo"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="date_closed" eval="(DateTime.today() - relativedelta(months=3)).strftime('%Y-%m-%d %H:%M')"/>
@@ -667,7 +667,7 @@ Andrew</p>]]></field>
<field name="partner_id" ref="base.res_partner_12"/>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor3')])]"/>
<field name="priority">1</field>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="user_id" ref="base.user_admin"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
@@ -703,7 +703,7 @@ Andrew</p>]]></field>
<field name="city">Lima</field>
<field name="priority">0</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor7')])]"/>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="user_id" ref="base.user_admin"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
@@ -775,7 +775,7 @@ Andrew</p>]]></field>
<field name="priority">2</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor2')])]"/>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(days=6)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="user_id" ref="base.user_demo"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
@@ -799,7 +799,7 @@ Andrew</p>]]></field>
<field name="priority">0</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1'), ref('sales_team.categ_oppor5')])]"/>
<field name="date_deadline" eval="DateTime.today() + relativedelta(days=12)"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="user_id" ref="base.user_demo"/>
<field name="stage_id" ref="crm.stage_lead1"/>
</record>
@@ -820,6 +820,8 @@ Andrew</p>]]></field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor6')])]"/>
<field name="priority">1</field>
<field name="date_open" eval="(DateTime.today() - relativedelta(months=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
@@ -842,6 +844,8 @@ Andrew</p>]]></field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor6')])]"/>
<field name="priority">1</field>
<field name="date_open" eval="(DateTime.today() - relativedelta(months=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="stage_lead1"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
@@ -892,246 +896,267 @@ Andrew</p>]]></field>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_1" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(months=1)"/>
<field name="type">lead</field>
<field name="name">Recurring delivery contract</field>
<field name="expected_revenue">36221</field>
<field name="probability">20</field>
<field name="contact_name">Max Johnson</field>
<field name="email_from">max123@itconsult.com</field>
<field name="country_id" ref="base.it"/>
<field name="city">Milan</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_1" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(months=1)"/>
<field name="type">lead</field>
<field name="name">Recurring delivery contract</field>
<field name="expected_revenue">36221</field>
<field name="probability">20</field>
<field name="contact_name">Max Johnson</field>
<field name="email_from">max123@itconsult.com</field>
<field name="country_id" ref="base.it"/>
<field name="city">Milan</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_2" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=18)"/>
<field name="type">lead</field>
<field name="name">Need info about pricing</field>
<field name="expected_revenue">28443</field>
<field name="probability">80</field>
<field name="contact_name">Aloysius Akred</field>
<field name="email_from">aakreda@theglobeandmail.com</field>
<field name="country_id" ref="base.gr"/>
<field name="city">London</field>
<field name="street">1 Russel square</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_2" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=18)"/>
<field name="type">lead</field>
<field name="name">Need info about pricing</field>
<field name="expected_revenue">28443</field>
<field name="probability">80</field>
<field name="contact_name">Aloysius Akred</field>
<field name="email_from">aakreda@theglobeandmail.com</field>
<field name="country_id" ref="base.gr"/>
<field name="city">London</field>
<field name="street">1 Russel square</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_3" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=24)"/>
<field name="type">lead</field>
<field name="name">Trelian New Offices</field>
<field name="expected_revenue">88715</field>
<field name="probability">12</field>
<field name="contact_name">Rosalynd Oxshott</field>
<field name="partner_name">Photobug</field>
<field name="email_from">roxshott9@trellian.com</field>
<field name="country_id" ref="base.br"/>
<field name="city">Canguaretama</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_3" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=24)"/>
<field name="type">lead</field>
<field name="name">Trelian New Offices</field>
<field name="expected_revenue">88715</field>
<field name="probability">12</field>
<field name="contact_name">Rosalynd Oxshott</field>
<field name="partner_name">Photobug</field>
<field name="email_from">roxshott9@trellian.com</field>
<field name="country_id" ref="base.br"/>
<field name="city">Canguaretama</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_4" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=40)"/>
<field name="type">lead</field>
<field name="name">Branded Furniture</field>
<field name="expected_revenue">7784</field>
<field name="probability">20</field>
<field name="contact_name">Myrna Limprecht</field>
<field name="email_from">mlimprecht8@fastcompany.com</field>
<field name="country_id" ref="base.pt"/>
<field name="city">Valejas</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_4" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=40)"/>
<field name="type">lead</field>
<field name="name">Branded Furniture</field>
<field name="expected_revenue">7784</field>
<field name="probability">20</field>
<field name="contact_name">Myrna Limprecht</field>
<field name="email_from">mlimprecht8@fastcompany.com</field>
<field name="country_id" ref="base.pt"/>
<field name="city">Valejas</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_5" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=45)"/>
<field name="type">lead</field>
<field name="name">Design New Shelves</field>
<field name="expected_revenue">54587</field>
<field name="probability">81</field>
<field name="contact_name">Alys Kalinovich</field>
<field name="partner_name">Jaloo</field>
<field name="email_from">akalinovich7@tinypic.com</field>
<field name="country_id" ref="base.id"/>
<field name="city">Boafeo</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_5" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=45)"/>
<field name="type">lead</field>
<field name="name">Design New Shelves</field>
<field name="expected_revenue">54587</field>
<field name="probability">81</field>
<field name="contact_name">Alys Kalinovich</field>
<field name="partner_name">Jaloo</field>
<field name="email_from">akalinovich7@tinypic.com</field>
<field name="country_id" ref="base.id"/>
<field name="city">Boafeo</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_6" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=48)"/>
<field name="type">lead</field>
<field name="name">Office chairs</field>
<field name="expected_revenue">5474</field>
<field name="probability">20</field>
<field name="contact_name">Jennine Jobbins</field>
<field name="partner_name">Shufflebeat</field>
<field name="email_from">jjobbins6@simplemachines.org</field>
<field name="country_id" ref="base.ru"/>
<field name="city">Gvardeysk</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_6" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=48)"/>
<field name="type">lead</field>
<field name="name">Office chairs</field>
<field name="expected_revenue">5474</field>
<field name="probability">20</field>
<field name="contact_name">Jennine Jobbins</field>
<field name="partner_name">Shufflebeat</field>
<field name="email_from">jjobbins6@simplemachines.org</field>
<field name="country_id" ref="base.ru"/>
<field name="city">Gvardeysk</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_7" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=80)"/>
<field name="type">lead</field>
<field name="name">Cleaning subscription</field>
<field name="expected_revenue">11475</field>
<field name="probability">25</field>
<field name="contact_name">Elmo Espinazo</field>
<field name="partner_name">Realblab</field>
<field name="email_from">eespinazo5@reuters.com</field>
<field name="country_id" ref="base.nl"/>
<field name="city">Amsterdam</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor3')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead1"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_services"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_7" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=80)"/>
<field name="type">lead</field>
<field name="name">Cleaning subscription</field>
<field name="expected_revenue">11475</field>
<field name="probability">25</field>
<field name="contact_name">Elmo Espinazo</field>
<field name="partner_name">Realblab</field>
<field name="email_from">eespinazo5@reuters.com</field>
<field name="country_id" ref="base.nl"/>
<field name="city">Amsterdam</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor3')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead1"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_services"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_8" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=37)"/>
<field name="type">lead</field>
<field name="name">Custom Desks (100 pieces)</field>
<field name="expected_revenue">9987</field>
<field name="probability">20</field>
<field name="contact_name">Chalmers Redford</field>
<field name="partner_name">Muxo</field>
<field name="email_from">credford4@salon.com</field>
<field name="country_id" ref="base.ru"/>
<field name="city">Odoyev</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" ref="sales_team.crm_team_1"/>
<field name="user_id" ref="base.user_admin"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_8" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=37)"/>
<field name="type">lead</field>
<field name="name">Custom Desks (100 pieces)</field>
<field name="expected_revenue">9987</field>
<field name="probability">20</field>
<field name="contact_name">Chalmers Redford</field>
<field name="partner_name">Muxo</field>
<field name="email_from">credford4@salon.com</field>
<field name="country_id" ref="base.ru"/>
<field name="city">Odoyev</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="user_id" ref="base.user_admin"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_9" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=32)"/>
<field name="type">lead</field>
<field name="name">Need a price: urgent</field>
<field name="expected_revenue">4482</field>
<field name="probability">90</field>
<field name="contact_name">Itch Kirvell</field>
<field name="partner_name">Skibox</field>
<field name="email_from">ikirvell3@gnu.org</field>
<field name="country_id" ref="base.id"/>
<field name="city">Dahu Satu</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_9" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=32)"/>
<field name="type">lead</field>
<field name="name">Need a price: urgent</field>
<field name="expected_revenue">4482</field>
<field name="probability">90</field>
<field name="contact_name">Itch Kirvell</field>
<field name="partner_name">Skibox</field>
<field name="email_from">ikirvell3@gnu.org</field>
<field name="country_id" ref="base.id"/>
<field name="city">Dahu Satu</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_10" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=18)"/>
<field name="type">lead</field>
<field name="name">Furnitures for new location</field>
<field name="expected_revenue">22500</field>
<field name="probability">20</field>
<field name="contact_name">Napoleon Grabert</field>
<field name="partner_name">Twinte</field>
<field name="email_from">ngrabert2@dailymail.co.uk</field>
<field name="country_id" ref="base.jp"/>
<field name="city">Tokyo</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_10" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=18)"/>
<field name="type">lead</field>
<field name="name">Furnitures for new location</field>
<field name="expected_revenue">22500</field>
<field name="probability">20</field>
<field name="contact_name">Napoleon Grabert</field>
<field name="partner_name">Twinte</field>
<field name="email_from">ngrabert2@dailymail.co.uk</field>
<field name="country_id" ref="base.jp"/>
<field name="city">Tokyo</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead2"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_11" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=52)"/>
<field name="type">lead</field>
<field name="name">Modernize old offices</field>
<field name="expected_revenue">99755</field>
<field name="probability">20</field>
<field name="contact_name">Franny Seiller</field>
<field name="partner_name">Yozio</field>
<field name="email_from">fseiller1@theglobeandmail.com</field>
<field name="country_id" ref="base.id"/>
<field name="city">Wurigelebur</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_11" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=52)"/>
<field name="type">lead</field>
<field name="name">Modernize old offices</field>
<field name="expected_revenue">99755</field>
<field name="probability">20</field>
<field name="contact_name">Franny Seiller</field>
<field name="partner_name">Yozio</field>
<field name="email_from">fseiller1@theglobeandmail.com</field>
<field name="country_id" ref="base.id"/>
<field name="city">Wurigelebur</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_12" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=52)"/>
<field name="type">lead</field>
<field name="name">Quote for 35 windows</field>
<field name="expected_revenue">41421</field>
<field name="probability">20</field>
<field name="contact_name">Tommi Brockhouse</field>
<field name="partner_name">Gabcube</field>
<field name="email_from">tbrockhouse0@google.pl</field>
<field name="country_id" ref="base.be"/>
<field name="city">Brussels</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<record id="crm_case_graph_12" model="crm.lead">
<field name="create_date" eval="datetime.now() - relativedelta(days=52)"/>
<field name="type">lead</field>
<field name="name">Quote for 35 windows</field>
<field name="expected_revenue">41421</field>
<field name="probability">20</field>
<field name="contact_name">Tommi Brockhouse</field>
<field name="partner_name">Gabcube</field>
<field name="email_from">tbrockhouse0@google.pl</field>
<field name="country_id" ref="base.be"/>
<field name="city">Brussels</field>
<field name="tag_ids" eval="[(6, 0, [ref('sales_team.categ_oppor1')])]"/>
<field name="priority">1</field>
<field name="date_deadline" eval="(DateTime.today() + relativedelta(weeks=2)).strftime('%Y-%m-%d %H:%M')"/>
<field name="team_id" eval="False"/>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="crm.stage_lead3"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="utm.utm_source_search_engine"/>
</record>
<!-- Call Function to set dome opportunities as Lost -->
+5
View File
@@ -1,8 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="sales_team.team_sales_department" model="crm.team" forcecreate="False">
<field name="assignment_domain">[['probability', '>=', '20']]</field>
</record>
<record id="sales_team.crm_team_1" model="crm.team">
<field name="use_leads">True</field>
<field name="assignment_domain">[['phone', '!=', False]]</field>
</record>
</data>
</odoo>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record id="sales_team.crm_team_member_admin_sales" model="crm.team.member">
<field name="assignment_max">30</field>
<field name="assignment_domain">[['probability', '>=', 20]]</field>
</record>
<record id="sales_team.crm_team_member_demo_team_1" model="crm.team.member">
<field name="assignment_max">45</field>
<field name="assignment_domain">[['probability', '>=', 5]]</field>
</record>
</data></odoo>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record forcecreate="True" id="ir_cron_crm_lead_assign" model="ir.cron">
<field name="name">CRM: Lead Assignment</field>
<field name="model_id" ref="crm.model_crm_team"/>
<field name="state">code</field>
<field name="code">model._cron_assign_leads()</field>
<field name="active" eval="False"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
</record>
</data></odoo>
+1
View File
@@ -7,6 +7,7 @@ from . import crm_lead
from . import crm_lost_reason
from . import crm_stage
from . import crm_team
from . import crm_team_member
from . import ir_config_parameter
from . import res_config_settings
from . import res_partner
+68 -13
View File
@@ -3,16 +3,16 @@
import logging
import threading
from collections import OrderedDict, defaultdict
from datetime import date, datetime, timedelta
from psycopg2 import sql
from odoo import api, fields, models, tools, SUPERUSER_ID
from odoo.addons.phone_validation.tools import phone_validation
from odoo.exceptions import UserError, AccessError
from odoo.osv import expression
from odoo.tools.translate import _
from odoo.tools import email_re, email_split
from odoo.exceptions import UserError, AccessError
from collections import OrderedDict, defaultdict
from odoo.tools import email_re, email_split, safe_eval
from . import crm_stage
@@ -84,15 +84,24 @@ class Lead(models.Model):
'format.address.mixin',
]
_primary_email = 'email_from'
_check_company_auto = True
# Description
name = fields.Char(
'Opportunity', index=True, required=True,
compute='_compute_name', readonly=False, store=True)
user_id = fields.Many2one('res.users', string='Salesperson', index=True, tracking=True, default=lambda self: self.env.user)
user_id = fields.Many2one(
'res.users', string='Salesperson', default=lambda self: self.env.user,
domain="['&', ('share', '=', False), ('company_ids', 'in', user_company_ids)]",
check_company=True, index=True, tracking=True)
user_company_ids = fields.Many2many(
'res.company', compute='_compute_user_company_ids',
help='UX: Limit to lead company or all if no company')
user_email = fields.Char('User Email', related='user_id.email', readonly=True)
user_login = fields.Char('User Login', related='user_id.login', readonly=True)
company_id = fields.Many2one('res.company', string='Company', index=True, default=lambda self: self.env.company.id)
company_id = fields.Many2one(
'res.company', string='Company', index=True,
compute='_compute_company_id', readonly=False, store=True)
referred = fields.Char('Referred By')
description = fields.Text('Notes')
active = fields.Boolean('Active', default=True, tracking=True)
@@ -104,7 +113,8 @@ class Lead(models.Model):
crm_stage.AVAILABLE_PRIORITIES, string='Priority', index=True,
default=crm_stage.AVAILABLE_PRIORITIES[0][0])
team_id = fields.Many2one(
'crm.team', string='Sales Team', index=True, tracking=True,
'crm.team', string='Sales Team', check_company=True, index=True, tracking=True,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
compute='_compute_team_id', readonly=False, store=True)
stage_id = fields.Many2one(
'crm.stage', string='Stage', index=True, tracking=True,
@@ -149,7 +159,7 @@ class Lead(models.Model):
date_deadline = fields.Date('Expected Closing', help="Estimate of the date on which the opportunity will be won.")
# Customer / contact
partner_id = fields.Many2one(
'res.partner', string='Customer', index=True, tracking=10,
'res.partner', string='Customer', check_company=True, index=True, tracking=10,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
help="Linked partner (optional). Usually created when converting the lead. You can find a partner by its Name, TIN, Email or Internal Reference.")
partner_is_blacklisted = fields.Boolean('Partner is blacklisted', related='partner_id.is_blacklisted', readonly=True)
@@ -240,6 +250,15 @@ class Lead(models.Model):
def _search_activity_date_deadline_my(self, operator, operand):
return ['&', ('activity_ids.user_id', '=', self._uid), ('activity_ids.date_deadline', operator, operand)]
@api.depends('company_id')
def _compute_user_company_ids(self):
all_companies = self.env['res.company'].search([])
for lead in self:
if not lead.company_id:
lead.user_company_ids = all_companies
else:
lead.user_company_ids = lead.company_id
@api.depends('user_id', 'type')
def _compute_team_id(self):
""" When changing the user, also set a team_id or restrict team id
@@ -249,12 +268,38 @@ class Lead(models.Model):
if not lead.user_id:
continue
user = lead.user_id
if lead.team_id and user in lead.team_id.member_ids | lead.team_id.user_id:
if lead.team_id and user in (lead.team_id.member_ids | lead.team_id.user_id):
continue
team_domain = [('use_leads', '=', True)] if lead.type == 'lead' else [('use_opportunities', '=', True)]
team = self.env['crm.team']._get_default_team_id(user_id=user.id, domain=team_domain)
lead.team_id = team.id
@api.depends('user_id', 'team_id')
def _compute_company_id(self):
""" Compute company_id coherency. """
for lead in self:
proposal = lead.company_id
# invalidate wrong configuration: company not in responsible companies or in team company if set
if proposal and lead.user_id and proposal not in lead.user_id.company_ids:
proposal = False
if proposal and lead.team_id.company_id and proposal != lead.team_id.company_id:
proposal = False
# propose a new company based on responsible, limited by team
if not proposal:
if not lead.user_id or lead.user_id == self.env.user:
proposal = self.env.company
elif lead.user_id:
proposal = lead.user_id.company_id
if lead.team_id.company_id and proposal != lead.team_id.company_id:
proposal = False
# set a new company
if lead.company_id != proposal:
lead.company_id = proposal
@api.depends('team_id', 'type')
def _compute_stage_id(self):
for lead in self:
@@ -1075,15 +1120,25 @@ class Lead(models.Model):
- merge at least 1 opp with anything else (lead or opp) = 1 new opp
The resulting lead/opportunity will be the most important one (based on its confidence level)
updated with values from other opportunities to merge.
:param user_id : the id of the saleperson. If not given, will be determined by `_merge_data`.
:param team : the id of the Sales Team. If not given, will be determined by `_merge_data`.
:return crm.lead record resulting of th merge
:param user_id : the id of the saleperson. If not given, will be determined by `_merge_data`.
:param team : the id of the Sales Team. If not given, will be determined by `_merge_data`.
:return crm.lead record resulting of th merge
"""
return self._merge_opportunity(user_id=user_id, team_id=team_id, auto_unlink=auto_unlink)
def _merge_opportunity(self, user_id=False, team_id=False, auto_unlink=True, max_length=5):
""" Private merging method. This one allows to relax rules on record set
length allowing to merge more than 5 opportunities at once if requested.
This should not be called by action buttons.
See ``merge_opportunity`` for more details. """
if len(self.ids) <= 1:
raise UserError(_('Please select more than one element (lead or opportunity) from the list view.'))
if len(self.ids) > 5 and not self.env.is_superuser():
raise UserError(_("To prevent data loss, Leads and Opportunities can only be merged by groups of 5."))
if max_length and len(self.ids) > max_length and not self.env.is_superuser():
raise UserError(_("To prevent data loss, Leads and Opportunities can only be merged by groups of %(max_length)s."))
opportunities = self._sort_by_confidence_level(reverse=True)
+409 -12
View File
@@ -1,12 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import datetime
import logging
import random
import threading
from odoo import api, fields, models, _
from ast import literal_eval
from odoo import api, exceptions, fields, models, _
from odoo.osv import expression
from odoo.tools.safe_eval import safe_eval
_logger = logging.getLogger(__name__)
class Team(models.Model):
_name = 'crm.team'
@@ -18,6 +25,16 @@ class Team(models.Model):
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.")
# assignment
assignment_enabled = fields.Boolean('Lead Assign', compute='_compute_assignment_enabled')
assignment_auto_enabled = fields.Boolean('Auto Assignment', compute='_compute_assignment_enabled')
assignment_optout = fields.Boolean('Skip auto assignment')
assignment_max = fields.Integer(
'Lead Capacity', compute='_compute_assignment_max',
help='Monthly leads for all salesmen belonging to the team')
assignment_domain = fields.Char(
'Assignment Domain', tracking=True,
help='Additional filter domain when fetching unassigned leads to allocate to the team.')
# statistics about leads / opportunities / both
lead_unassigned_count = fields.Integer(
string='# Unassigned Leads', compute='_compute_lead_unassigned_count')
@@ -37,6 +54,20 @@ class Team(models.Model):
'res.users', related='alias_id.alias_user_id', inherited=True,
domain=lambda self: [('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman_all_leads').id)])
@api.depends('crm_team_member_ids.assignment_max')
def _compute_assignment_max(self):
for team in self:
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)
auto_assign_enabled = False
if assign_enabled:
assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False)
auto_assign_enabled = assign_cron.active if assign_cron else False
self.assignment_enabled = assign_enabled
self.assignment_auto_enabled = auto_assign_enabled
def _compute_lead_unassigned_count(self):
leads_data = self.env['crm.lead'].read_group([
('team_id', 'in', self.ids),
@@ -47,16 +78,10 @@ class Team(models.Model):
for team in self:
team.lead_unassigned_count = counts.get(team.id, 0)
@api.depends('crm_team_member_ids.lead_month_count')
def _compute_lead_all_assigned_month_count(self):
limit_date = datetime.datetime.now() - datetime.timedelta(days=30)
leads_data = self.env['crm.lead'].read_group([
('team_id', 'in', self.ids),
('date_open', '>=', fields.Datetime.to_string(limit_date)),
('user_id', '!=', False),
], ['team_id'], ['team_id'])
counts = {datum['team_id'][0]: datum['team_id_count'] for datum in leads_data}
for team in self:
team.lead_all_assigned_month_count = counts.get(team.id, 0)
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([
@@ -88,6 +113,16 @@ class Team(models.Model):
if not self.use_leads and not self.use_opportunities:
self.alias_name = False
@api.constrains('assignment_domain')
def _constrains_assignment_domain(self):
for team in self:
try:
domain = literal_eval(team.assignment_domain or '[]')
if domain:
self.env['crm.lead'].search(domain, limit=1)
except Exception:
raise exceptions.ValidationError(_('Assignment domain for team %(team)s is incorrectly formatted', team=team.name))
# ------------------------------------------------------------
# ORM
# ------------------------------------------------------------
@@ -113,12 +148,374 @@ class Team(models.Model):
if self.id:
if not self.use_leads and not self.use_opportunities:
values['alias_name'] = False
values['alias_defaults'] = defaults = ast.literal_eval(self.alias_defaults or "{}")
values['alias_defaults'] = defaults = literal_eval(self.alias_defaults or "{}")
has_group_use_lead = self.env.user.has_group('crm.group_use_lead')
defaults['type'] = 'lead' if has_group_use_lead and self.use_leads else 'opportunity'
defaults['team_id'] = self.id
return values
# ------------------------------------------------------------
# LEAD ASSIGNMENT
# ------------------------------------------------------------
@api.model
def _cron_assign_leads(self):
""" Cron method assigning leads. Leads are allocated to all teams and
assigned to their members. It is based on cron configuration to
deduce parameters of assignment to perform. Purpose of cron is to assign
leads to sales persons. Assigned workload is set to 2 times the workload
those sales people should perform between two cron iterations. Giving
more allows more flexibility in their organization. If their maximum
capacity is reached assign process won't give more leads to those people.
e.g. cron is active with interval_number 3, interval_type days. This
means cron runs every 3 days. Cron will assign leads for 6 work days
to salespersons each 3 days unless their maximum capacity is reached.
If cron runs on an hour-based schedule minimum assignment performed is
equivalent to 2 workdays. Max assignment performed is for 30 days as it
is better to run more often than planning for more than one month.
See ``CrmTeam.action_assign_leads()`` and its sub methods for more
details about assign process.
"""
assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False)
work_days = 2
if 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 = 2 * assign_cron.interval_number * 7
elif assign_cron.interval_type == 'days':
work_days = 2 * assign_cron.interval_number * 1
work_days = 30 if work_days > 30 else work_days
self.env['crm.team'].search([
'&', '|', ('use_leads', '=', True), ('use_opportunities', '=', True),
('assignment_optout', '=', False)
])._action_assign_leads(work_days=work_days)
return True
def action_assign_leads(self, work_days=2, log=True):
""" Manual (direct) leads assignment. This method both
* assigns leads to teams given by self;
* assigns leads to salespersons belonging to self;
See sub methods for more details about assign process.
:param int 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
process;
"""
teams_data, members_data = self._action_assign_leads(work_days=work_days)
# format result messages
logs = self._action_assign_leads_logs(teams_data, members_data)
html_message = '<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)
self._message_log_batch(bodies=dict((team.id, log_message) for team in self))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'title': _("Leads Assigned"),
'message': notif_message,
'next': {
'type': 'ir.actions.act_window_close'
},
}
}
def _action_assign_leads(self, work_days=2):
""" Private method for lead assignment. This method both
* assigns leads to teams given by self;
* assigns leads to salespersons belonging to self;
See sub methods for more details about assign process.
:param int work_days: see ``CrmTeam.action_assign_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``;
"""
if not self.env.user.has_group('sales_team.group_sale_manager') and not self.env.user.has_group('base.group_system'):
raise exceptions.UserError(_('Lead/Opportunities automatic assignment is limited to managers or administrators'))
_logger.info('### START Lead Assignment (%d teams, %d sales persons, %d work_days)' % (len(self), len(self.crm_team_member_ids), work_days))
teams_data = self._allocate_leads(work_days=work_days)
_logger.info('### Team repartition done. Starting salesmen assignment.')
members_data = self.crm_team_member_ids._assign_and_convert_leads(work_days=work_days)
_logger.info('### END Lead Assignment')
return teams_data, members_data
def _action_assign_leads_logs(self, teams_data, members_data):
""" 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()``;
:return list: list of formatted logs, ready to be formatted into a nice
plaintext or html message at caller's will
"""
# extract some statistics
assigned = sum(len(teams_data[team]['assigned']) + len(teams_data[team]['merged']) for team in self)
duplicates = sum(len(teams_data[team]['duplicates']) for team in self)
members = len(members_data)
members_assigned = sum(len(member_data['assigned']) for member_data in members_data.values())
# format user notification
message_parts = []
# 1- duplicates removal
if duplicates:
message_parts.append(_("%(duplicates)s duplicates leads have been merged.",
duplicates=duplicates))
# 2- nothing assigned at all
if not assigned and not members_assigned:
if len(self) == 1:
if not self.assignment_max:
message_parts.append(
_("No allocated leads to %(team_name)s team because it has no capacity. Add capacity to its salespersons.",
team_name=self.name))
else:
message_parts.append(
_("No allocated leads to %(team_name)s team and its salespersons because no unassigned lead matches its domain.",
team_name=self.name))
else:
message_parts.append(
_("No allocated leads to any team or salesperson. Check your Sales Teams and Salespersons configuration as well as unassigned leads."))
# 3- team allocation
if not assigned and members_assigned:
if len(self) == 1:
message_parts.append(
_("No new lead allocated to %(team_name)s team because no unassigned lead matches its domain.",
team_name=self.name))
else:
message_parts.append(_("No new lead allocated to the teams because no lead match their domains."))
elif assigned:
if len(self) == 1:
message_parts.append(
_("%(assigned)s leads allocated to %(team_name)s team.",
assigned=assigned, team_name=self.name))
else:
message_parts.append(
_("%(assigned)s leads allocated among %(team_count)s teams.",
assigned=assigned, team_count=len(self)))
# 4- salespersons assignment
if not members_assigned and assigned:
message_parts.append(
_("No lead assigned to salespersons because no unassigned lead matches their domains."))
elif members_assigned:
message_parts.append(
_("%(members_assigned)s leads assigned among %(member_count)s salespersons.",
members_assigned=members_assigned, member_count=members))
return message_parts
def _allocate_leads(self, work_days=2):
""" 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
allocate leads within teams.
Heuristic of this method is the following:
* first we randomize all teams;
* then for each team
* find unassigned leads, 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;
* if set, a delay after creation can be applied (see BUNDLE_HOURS_DELAY)
parameter explanations here below;
* keep only leads matching the team's assignment domain (empty means
everything);
* assign maximum BUNDLE_SIZE leads to the team, then move to the
next team. This is done to ensure every team will have leads
enough to fill its capacity based on its domain;
* when setting a team on leads, leads belonging to the current batch
are also merged. Purpose is to clean database and avoid assigning
duplicates to same or different teams;
* evaluate which teams still need to receive leads. This is based on
team maximum capacity. We consider a team should receive twice its
capacity as leads. That way members will receive leads and can pick
some leads in team unassigned pool of leads;
Note that leads are assigned in batch meaning a team could receive
leads that could better fit another team. However this heuristics is
based on hypothesis that team domains do not overlap. Indeed if a
company has several teams they will probably target separate market
segments: country-based, customer type or size, ... Having several
teams using same assignment domain could lead to less fairness in
assignment process but this should not be the target use case of this
heuristic.
Leads are allocated by batch. This can be configured using a config
parameter (see here below). Batch size depends on cron frequency,
lead pipeline size and members assignment maximum. Finding an optimal
heuristic for this parameter is not easy as it depends on internal
processes and organization. Higher batch size leads to better performances
when running automatic assignment. It can also give unfair results
if teams domain overlap or if pipeline is not big enough to fill all
teams capacity.
:config int crm.assignment.bundle: optional config parameter allowing
to set size of lead batch (BUNDLE_SIZE) allocated to a team at each
iteration (50 by default based on experience);
: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;
:param int work_days: see ``CrmTeam.action_assign_leads()``;
: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 not work_days or work_days > 30:
raise ValueError(
_('Leads team allocation should be done for at least 1 or maximum 30 work days, not %s.', work_days)
)
# assignment_max is valid for "30 days" -> divide by requested work_days
# to have number of leads to assign
assign_ratio = work_days / 30.0
BUNDLE_HOURS_DELAY = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.delay', default=0))
BUNDLE_SIZE = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.bundle', default=50))
max_create_dt = fields.Datetime.now() - datetime.timedelta(hours=BUNDLE_HOURS_DELAY)
team_done = self.env['crm.team']
remaining_teams = self.env['crm.team'].browse(random.sample(self.ids, k=len(self.ids)))
# compute assign domain for each team before looping on them by bundle size
teams_domain = dict(
(team, literal_eval(team.assignment_domain or '[]'))
for team in remaining_teams
)
# compute limit of leads to assign to each team: 2 times team capacity, based on given work_days
teams_limit = dict(
(team, 2 * team.assignment_max * assign_ratio)
for team in remaining_teams
)
# assignment process data
teams_data = dict.fromkeys(remaining_teams, False)
for team in remaining_teams:
teams_data[team] = dict(assigned=set(), merged=set(), duplicates=set())
remaining_teams = remaining_teams.filtered('assignment_max')
while remaining_teams:
for team in remaining_teams:
lead_domain = expression.AND([
teams_domain[team],
[('create_date', '<', max_create_dt)],
['&', ('team_id', '=', False), ('user_id', '=', False)],
['|', ('stage_id.is_won', '=', False), ('probability', 'not in', [False, 0])]
])
# assign only to reach asked team limit
remaining = teams_limit[team] - (len(teams_data[team]['assigned']) + len(teams_data[team]['merged']))
lead_limit = min([BUNDLE_SIZE, remaining if remaining > 0 else 1])
leads = self.env["crm.lead"].search(lead_domain, limit=lead_limit)
# assign + deduplicate and concatenate results in teams_data to keep some history
assign_res = team._allocate_leads_deduplicate(leads)
_logger.info('Assigned %d leads among %d candidates to team %s' % (len(assign_res['assigned']) + len(assign_res['merged']), len(leads), team.id))
_logger.info('\tLeads: direct assign %s / merge result %s / duplicates merged: %s' % (
assign_res['assigned'], assign_res['merged'], assign_res['duplicates']
))
for key in ('assigned', 'merged', 'duplicates'):
teams_data[team][key].update(assign_res[key])
# either no more lead matching domain, either asked capacity assigned
if len(leads) < lead_limit or (len(teams_data[team]['assigned']) + len(teams_data[team]['merged'])) >= teams_limit[team]:
team_done += team
# auto-commit except in testing mode. As this process may be time consuming or we
# may encounter errors, already commit what is allocated to avoid endless cron loops.
auto_commit = not getattr(threading.currentThread(), 'testing', False)
if auto_commit:
self._cr.commit()
remaining_team_ids = (remaining_teams - team_done).ids
remaining_teams = self.env['crm.team'].browse(random.sample(remaining_team_ids, k=len(remaining_team_ids)))
# some final log
_logger.info('## Assigned %s leads' % sum(len(team_data['assigned']) + len(team_data['merged']) for team_data in teams_data.values()))
return teams_data
def _allocate_leads_deduplicate(self, leads):
""" Assign leads to sales team given by self by calling lead tool
method _handle_salesmen_assignment. In this method we deduplicate leads
allowing to reduce number of resulting leads before assigning them
to salesmen.
:param leads: recordset of leads to assign to current team;
"""
self.ensure_one()
# classify leads
leads_assigned = self.env['crm.lead'] # direct team assign
leads_done_ids, leads_merged_ids, leads_dup_ids = set(), set(), set() # classification
leads_dups_dict = dict() # lead -> its duplicate
for lead in leads:
if lead.id not in leads_done_ids:
lead_duplicates = lead._get_lead_duplicates(email=lead.email_from)
if len(lead_duplicates) > 1:
leads_dups_dict[lead] = lead_duplicates
leads_done_ids.update((lead + lead_duplicates).ids)
else:
leads_assigned += lead
leads_done_ids.add(lead.id)
leads_assigned._handle_salesmen_assignment(user_ids=None, team_id=self.id)
for lead in leads.filtered(lambda lead: lead in leads_dups_dict):
lead_duplicates = leads_dups_dict[lead]
merged = lead_duplicates._merge_opportunity(user_id=False, team_id=self.id, max_length=0)
leads_dup_ids.update((lead_duplicates - merged).ids)
leads_merged_ids.add(merged.id)
# auto-commit except in testing mode
auto_commit = not getattr(threading.currentThread(), 'testing', False)
if auto_commit:
self._cr.commit()
return {
'assigned': set(leads_assigned.ids),
'merged': leads_merged_ids,
'duplicates': leads_dup_ids,
}
# ------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------
@@ -180,4 +577,4 @@ class Team(models.Model):
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 super(Team, self)._graph_title_and_key()
+200
View File
@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
import logging
import math
import threading
import random
from ast import literal_eval
from odoo import api, exceptions, fields, models, _
from odoo.osv import expression
_logger = logging.getLogger(__name__)
class Team(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_max = fields.Integer('Max Leads (last 30 days)', default=30)
lead_month_count = fields.Integer(
'Leads (30 days)', compute='_compute_lead_month_count',
help='Lead assigned to this member those last 30 days')
@api.depends('user_id', 'crm_team_id')
def _compute_lead_month_count(self):
for member in self:
if member.user_id.id and member.crm_team_id.id:
member.lead_month_count = self.env['crm.lead'].search_count(member._get_lead_month_domain())
else:
member.lead_month_count = 0
@api.constrains('assignment_domain')
def _constrains_assignment_domain(self):
for member in self:
try:
domain = literal_eval(member.assignment_domain or '[]')
if domain:
self.env['crm.lead'].search(domain, limit=1)
except Exception:
raise exceptions.ValidationEreror(_(
'Member assignment domain for user %(user)s and team %(team)s is incorrectly formatted',
user=member.user_id.name, team=member.crm_team_id.name
))
def _get_lead_month_domain(self):
limit_date = fields.Datetime.now() - datetime.timedelta(days=30)
return [
('user_id', '=', self.user_id.id),
('team_id', '=', self.crm_team_id.id),
('date_open', '>=', limit_date),
('probability', '<', 100)
]
# ------------------------------------------------------------
# LEAD ASSIGNMENT
# ------------------------------------------------------------
def _assign_and_convert_leads(self, work_days=2):
""" 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 int work_days: see ``CrmTeam.action_assign_leads()``;
:return members_data: dict() with each member assignment result:
membership: {
'assigned': set of lead IDs directly assigned to the member;
}, ...
"""
if not work_days or work_days > 30:
raise ValueError(
_('Leads assignment should be done for at least 1 or maximum 30 work days, not %s.', work_days)
)
# assignment_max is valid for "30 days" -> divide by requested work_days
# to have number of leads to assign
assign_ratio = work_days / 30.0
members_data, population, weights = dict(), list(), list()
members = self.filtered(lambda member: member.assignment_max > member.lead_month_count)
if not members:
return members_data
# prepare a global lead count based on total leads to assign to salespersons
lead_limit = sum(
min(
int(math.ceil(member.assignment_max * assign_ratio)),
(member.assignment_max - member.lead_month_count)
)
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', limit=lead_limit)
to_assign = min(member.assignment_max - member.lead_month_count, round(member.assignment_max * assign_ratio))
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
while population:
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.id,
user_ids=member_data['team_member'].user_id.ids
)
# auto-commit except in testing mode
auto_commit = not getattr(threading.currentThread(), 'testing', False)
if auto_commit:
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()
# 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(self)))
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
+74 -1
View File
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from dateutil.relativedelta import relativedelta
from odoo import api, exceptions, fields, models, _
class ResConfigSettings(models.TransientModel):
@@ -9,6 +11,30 @@ class ResConfigSettings(models.TransientModel):
group_use_lead = fields.Boolean(string="Leads", implied_group='crm.group_use_lead')
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')
# Lead assignment
crm_use_auto_assignment = fields.Boolean(
string='Rule-Based Assignment', config_parameter='crm.lead.auto.assignment')
crm_auto_assignment_action = fields.Selection([
('manual', 'Manually'), ('auto', 'Repeatedly')],
string='Auto Assignment Action', compute='_compute_crm_auto_assignment_data',
readonly=False, store=True,
help='Manual assign allow to trigger assignment from team form view using an action button. Automatic configures a cron running repeatedly assignment in all teams.')
crm_auto_assignment_interval_type = fields.Selection([
('minutes', 'Minutes'), ('hours', 'Hours'),
('days', 'Days'), ('weeks', 'Weeks')],
string='Auto Assignment Interval Unit', compute='_compute_crm_auto_assignment_data',
readonly=False, store=True,
help='Interval type between each cron run (e.g. each 2 days or each 2 hours)')
crm_auto_assignment_interval_number = fields.Integer(
string="Repeat every", compute='_compute_crm_auto_assignment_data',
readonly=False, store=True,
help='Number of interval type between each cron run (e.g. each 2 days or each 4 days)')
crm_auto_assignment_run_datetime = fields.Datetime(
string="Auto Assignment Next Execution Date", compute='_compute_crm_auto_assignment_data',
readonly=False, store=True)
# IAP
module_crm_iap_lead = fields.Boolean("Generate new leads based on their country, industries, size, etc.")
module_crm_iap_lead_website = fields.Boolean("Create Leads/Opportunities from your website's traffic")
module_crm_iap_lead_enrich = fields.Boolean("Enrich your leads automatically with company data based on their email address.")
@@ -23,6 +49,32 @@ class ResConfigSettings(models.TransientModel):
predictive_lead_scoring_fields = fields.Many2many('crm.lead.scoring.frequency.field', string='Lead Scoring Frequency Fields', compute="_compute_pls_fields", inverse="_inverse_pls_fields_str")
predictive_lead_scoring_fields_str = fields.Char(string='Lead Scoring Frequency Fields in String', config_parameter='crm.pls_fields')
@api.depends('crm_use_auto_assignment')
def _compute_crm_auto_assignment_data(self):
assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False)
for setting in self:
if setting.crm_use_auto_assignment and assign_cron:
setting.crm_auto_assignment_action = 'auto' if assign_cron.active else 'manual'
setting.crm_auto_assignment_interval_type = assign_cron.interval_type or 'days'
setting.crm_auto_assignment_interval_number = assign_cron.interval_number or 1
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_number = 1
@api.onchange('crm_auto_assignment_interval_type', 'crm_auto_assignment_interval_number')
def _onchange_crm_auto_assignment_run_datetime(self):
if self.crm_auto_assignment_interval_number <= 0:
raise exceptions.UserError(_('Repeat frequency should be positive.'))
elif self.crm_auto_assignment_interval_number >= 100:
raise exceptions.UserError(_('Invalid repeat frequency. Consider changing frequency type instead of using large numbers.'))
self.crm_auto_assignment_run_datetime = self._get_crm_auto_assignmment_run_datetime(
self.crm_auto_assignment_run_datetime,
self.crm_auto_assignment_interval_type,
self.crm_auto_assignment_interval_number
)
@api.depends('predictive_lead_scoring_fields_str')
def _compute_pls_fields(self):
""" As config_parameters does not accept m2m field,
@@ -61,14 +113,35 @@ class ResConfigSettings(models.TransientModel):
def set_values(self):
group_lead_before = self.env.ref('crm.group_use_lead') in self.env.user.groups_id
super(ResConfigSettings, self).set_values()
# update use leads / opportunities setting on all teams according to settings update
group_lead_after = self.env.ref('crm.group_use_lead') in self.env.user.groups_id
if group_lead_before != group_lead_after:
teams = self.env['crm.team'].search([])
teams.filtered('use_opportunities').use_leads = group_lead_after
for team in teams:
team.alias_id.write(team._alias_get_creation_values())
# synchronize cron with settings
assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False)
if assign_cron:
assign_cron.active = self.crm_use_auto_assignment and self.crm_auto_assignment_action == 'auto'
assign_cron.interval_type = self.crm_auto_assignment_interval_type
assign_cron.interval_number = self.crm_auto_assignment_interval_number
# keep nextcall on cron as it is required whatever the setting
assign_cron.nextcall = self.crm_auto_assignment_run_datetime if self.crm_auto_assignment_run_datetime else assign_cron.nextcall
# TDE FIXME: re create cron if not found ?
def _get_crm_auto_assignmment_run_datetime(self, run_datetime, run_interval, run_interval_number):
if not run_interval:
return False
if run_interval == 'manual':
return run_datetime if run_datetime else False
return fields.Datetime.now() + relativedelta(**{run_interval: run_interval_number})
# ACTIONS
def action_reset_lead_probabilities(self):
if self.env.user._is_admin():
self.env['crm.lead'].sudo()._cron_update_automated_probabilities()
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)
@@ -0,0 +1,6 @@
.o_kanban_view.o_crm_team_member_kanban {
.o_member_assignment div.oe_gauge {
width: 100px;
height: 80px;
}
}
+1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from . import test_crm_lead
from . import test_crm_lead_assignment
from . import test_crm_lead_notification
from . import test_crm_lead_convert
from . import test_crm_lead_convert_mass
+155 -17
View File
@@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from unittest.mock import patch
from odoo.addons.mail.tests.common import MailCase, mail_new_test_user
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.sales_team.tests.common import TestSalesCommon
from odoo.fields import Datetime
from odoo import tools
@@ -51,6 +53,15 @@ class TestCrmCommon(TestSalesCommon, MailCase):
'alias_name': 'sales.test',
'use_leads': True,
'use_opportunities': True,
'assignment_domain': False,
})
cls.sales_team_1_m1.write({
'assignment_max': 45,
'assignment_domain': False,
})
cls.sales_team_1_m2.write({
'assignment_max': 15,
'assignment_domain': False,
})
(cls.user_sales_manager | cls.user_sales_leads | cls.user_sales_salesman).write({
@@ -151,37 +162,88 @@ class TestCrmCommon(TestSalesCommon, MailCase):
'zip': '97648',
})
def _create_leads_batch(self, lead_type='lead', count=10, partner_ids=None, user_ids=None):
def _create_leads_batch(self, lead_type='lead', count=10, email_dup_count=0,
partner_count=0, partner_ids=None, user_ids=None,
country_ids=None):
""" Helper tool method creating a batch of leads, useful when dealing
with batch processes. Please update me.
:param string type: 'lead', 'opportunity', 'mixed' (lead then opp),
None (depends on configuration);
:param partner_count: if not partner_ids is given, generate partner count
customers; other leads will have no customer;
:param partner_ids: a set of partner ids to cycle when creating leads;
:param user_ids: a set of user ids to cycle when creating leads;
:return: create leads
"""
types = ['lead', 'opportunity']
leads_data = [{
'name': 'TestLead_%02d' % (x),
'name': 'TestLead_%04d' % (x),
'type': lead_type if lead_type else types[x % 2],
'priority': '%s' % (x % 3),
} for x in range(count)]
# generate customer information
partners = []
if partner_count:
partners = self.env['res.partner'].create([{
'name': 'AutoPartner_%04d' % (x),
'email': tools.formataddr((
'AutoPartner_%04d' % (x),
'partner_email_%04d@example.com' % (x),
)),
} for x in range(partner_count)])
# customer information
if partner_ids:
for idx, lead_data in enumerate(leads_data):
lead_data['partner_id'] = partner_ids[idx % len(partner_ids)]
else:
for idx, lead_data in enumerate(leads_data):
lead_data['email_from'] = tools.formataddr((
'TestCustomer_%02d' % (idx),
'customer_email_%02d@example.com' % (idx)
))
if partner_count and idx < partner_count:
lead_data['partner_id'] = partners[idx].id
else:
lead_data['email_from'] = tools.formataddr((
'TestCustomer_%02d' % (idx),
'customer_email_%04d@example.com' % (idx)
))
# country + phone information
if country_ids:
cid_to_country = dict(
(country.id, country)
for country in self.env['res.country'].browse([cid for cid in country_ids if cid])
)
for idx, lead_data in enumerate(leads_data):
country_id = country_ids[idx % len(country_ids)]
country = cid_to_country.get(country_id, self.env['res.country'])
lead_data['country_id'] = country.id
if lead_data['country_id']:
lead_data['phone'] = phone_validation.phone_format(
'0456%04d99' % (idx),
country.code, country.phone_code,
force_format='E164')
else:
lead_data['phone'] = '+32456%04d99' % (idx)
# salesteam information
if user_ids:
for idx, lead_data in enumerate(leads_data):
lead_data['user_id'] = user_ids[idx % len(user_ids)]
return self.env['crm.lead'].create(leads_data)
# duplicates (currently only with email)
dups_data = []
if email_dup_count and not partner_ids:
for idx, lead_data in enumerate(leads_data):
if not lead_data.get('partner_id') and lead_data['email_from']:
dup_data = dict(lead_data)
dup_data['name'] = 'Duplicated-%s' % dup_data['name']
dups_data.append(dup_data)
if len(dups_data) >= email_dup_count:
break
return self.env['crm.lead'].create(leads_data + dups_data)
def _create_duplicates(self, lead, create_opp=True):
""" Helper tool method creating, based on a given lead
@@ -246,10 +308,10 @@ class TestLeadConvertCommon(TestCrmCommon):
# Sales Team organization
# Role: M (team member) R (team manager)
# SALESMAN---------------sales_team_1-----sales_team_convert
# admin------------------M----------------/
# admin------------------M----------------/ (sales_team_1_m2)
# user_sales_manager-----R----------------R
# user_sales_leads-------M----------------/
# user_sales_salesman----/----------------M
# user_sales_leads-------M----------------/ (sales_team_1_m1)
# user_sales_salesman----/----------------M (sales_team_convert_m1)
# Stages Team organization
# Name-------------------ST-------------------Sequ
@@ -268,7 +330,13 @@ class TestLeadConvertCommon(TestCrmCommon):
'use_opportunities': True,
'company_id': False,
'user_id': cls.user_sales_manager.id,
'member_ids': [(4, cls.user_sales_salesman.id)],
'assignment_domain': [('priority', 'in', ['1', '2', '3'])],
})
cls.sales_team_convert_m1 = cls.env['crm.team.member'].create({
'user_id': cls.user_sales_salesman.id,
'crm_team_id': cls.sales_team_convert.id,
'assignment_max': 30,
'assignment_domain': False,
})
cls.stage_team_convert_1 = cls.env['crm.stage'].create({
'name': 'New',
@@ -286,6 +354,75 @@ class TestLeadConvertCommon(TestCrmCommon):
cls.crm_lead_dt_patcher.stop()
super(TestLeadConvertCommon, cls).tearDownClass()
@classmethod
def _switch_to_multi_membership(cls):
# Sales Team organization
# Role: M (team member) R (team manager)
# SALESMAN---------------sales_team_1-----sales_team_convert
# admin------------------M----------------/ (sales_team_1_m2)
# user_sales_manager-----R----------------R+M <-- NEW (sales_team_convert_m2)
# user_sales_leads-------M----------------/ (sales_team_1_m1)
# user_sales_salesman----M----------------M <-- NEW (sales_team_1_m3 / sales_team_convert_m1)
# SALESMAN--------------sales_team----------assign_max
# admin-----------------sales_team_1--------15 (tot: 0.5/day)
# user_sales_manager----sales_team_convert--60 (tot: 2/day)
# user_sales_leads------sales_team_1--------45 (tot: 1.5/day)
# user_sales_salesman---sales_team_1--------15 (tot: 1.5/day)
# user_sales_salesman---sales_team_convert--30
cls.sales_team_1_m1.write({
'assignment_max': 45,
'assignment_domain': False,
})
cls.sales_team_1_m2.write({
'assignment_max': 15,
'assignment_domain': [('probability', '>=', 10)],
})
cls.env['ir.config_parameter'].set_param('sales_team.membership_multi', True)
cls.sales_team_1_m3 = cls.env['crm.team.member'].create({
'user_id': cls.user_sales_salesman.id,
'crm_team_id': cls.sales_team_1.id,
'assignment_max': 15,
'assignment_domain': [('probability', '>=', 20)],
})
cls.sales_team_convert_m1.write({
'assignment_max': 30,
'assignment_domain': [('priority', 'in', ['2', '3'])]
})
cls.sales_team_convert_m2 = cls.env['crm.team.member'].create({
'user_id': cls.user_sales_manager.id,
'crm_team_id': cls.sales_team_convert.id,
'assignment_max': 60,
'assignment_domain': False,
})
@classmethod
def _switch_to_auto_assign(cls):
cls.env['ir.config_parameter'].set_param('crm.lead.auto.assignment', True)
cls.assign_cron = cls.env.ref('crm.ir_cron_crm_lead_assign')
cls.assign_cron.update({
'active': True,
'interval_type': 'days',
'interval_number': 1,
})
def assertMemberAssign(self, member, count):
""" Check assign result and that domains are effectively taken into account """
self.assertEqual(member.lead_month_count, count)
member_leads = self.env['crm.lead'].search(member._get_lead_month_domain())
self.assertEqual(len(member_leads), count)
if member.assignment_domain:
self.assertEqual(
member_leads.filtered_domain(literal_eval(member.assignment_domain)),
member_leads
)
if member.crm_team_id.assignment_domain:
self.assertEqual(
member_leads.filtered_domain(literal_eval(member.crm_team_id.assignment_domain)),
member_leads
)
class TestLeadConvertMassCommon(TestLeadConvertCommon):
@@ -295,11 +432,11 @@ class TestLeadConvertMassCommon(TestLeadConvertCommon):
# Sales Team organization
# Role: M (team member) R (team manager)
# SALESMAN-------------------sales_team_1-----sales_team_convert
# admin----------------------M----------------/
# user_sales_manager---------R----------------R
# admin----------------------M----------------/ (sales_team_1_m2)
# user_sales_manager---------R----------------R (sales_team_1_m1)
# user_sales_leads-----------M----------------/
# user_sales_leads_convert---/----------------M <-- NEW
# user_sales_salesman--------/----------------M
# user_sales_leads_convert---/----------------M <-- NEW (sales_team_convert_m2)
# user_sales_salesman--------/----------------M (sales_team_convert_m1)
cls.user_sales_leads_convert = mail_new_test_user(
cls.env, login='user_sales_leads_convert',
@@ -308,8 +445,9 @@ class TestLeadConvertMassCommon(TestLeadConvertCommon):
notification_type='inbox',
groups='sales_team.group_sale_salesman_all_leads,base.group_partner_manager,crm.group_use_lead',
)
cls.sales_team_convert.write({
'member_ids': [(4, cls.user_sales_leads_convert.id)]
cls.sales_team_convert_m2 = cls.env['crm.team.member'].create({
'user_id': cls.user_sales_leads_convert.id,
'crm_team_id': cls.sales_team_convert.id,
})
cls.lead_w_partner = cls.env['crm.lead'].create({
@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from dateutil.relativedelta import relativedelta
from unittest.mock import patch
from odoo import fields
from odoo.addons.crm.tests.common import TestLeadConvertCommon
from odoo.tests.common import tagged
@tagged('lead_assign')
class TestLeadAssign(TestLeadConvertCommon):
""" Test lead assignment feature added in saas-13.5. """
@classmethod
def setUpClass(cls):
super(TestLeadAssign, cls).setUpClass()
cls._switch_to_multi_membership()
cls._switch_to_auto_assign()
# don't mess with existing teams, deactivate them to make tests repeatable
cls.sales_teams = cls.sales_team_1 + cls.sales_team_convert
cls.members = cls.sales_team_1_m1 | cls.sales_team_1_m2 | cls.sales_team_1_m3 | cls.sales_team_convert_m1 | cls.sales_team_convert_m2
cls.env['crm.team'].search([('id', 'not in', cls.sales_teams.ids)]).write({'active': False})
# don't mess with existing leads, deactivate those assigned to users used here to make tests repeatable
cls.env['crm.lead'].search(['|', ('team_id', '=', False), ('user_id', 'in', cls.sales_teams.member_ids.ids)]).write({'active': False})
cls.bundle_size = 5
cls.env['ir.config_parameter'].set_param('crm.assignment.bundle', '%s' % cls.bundle_size)
cls.env['ir.config_parameter'].set_param('crm.assignment.delay', '0')
def assertInitialData(self):
self.assertEqual(self.sales_team_1.assignment_max, 75)
self.assertEqual(self.sales_team_convert.assignment_max, 90)
# ensure domains
self.assertEqual(self.sales_team_1.assignment_domain, False)
self.assertEqual(self.sales_team_1_m1.assignment_domain, False)
self.assertEqual(self.sales_team_1_m2.assignment_domain, "[('probability', '>=', 10)]")
self.assertEqual(self.sales_team_1_m3.assignment_domain, "[('probability', '>=', 20)]")
self.assertEqual(self.sales_team_convert.assignment_domain, "[('priority', 'in', ['1', '2', '3'])]")
self.assertEqual(self.sales_team_convert_m1.assignment_domain, "[('priority', 'in', ['2', '3'])]")
self.assertEqual(self.sales_team_convert_m2.assignment_domain, False)
# start afresh
self.assertEqual(self.sales_team_1_m1.lead_month_count, 0)
self.assertEqual(self.sales_team_1_m2.lead_month_count, 0)
self.assertEqual(self.sales_team_1_m3.lead_month_count, 0)
self.assertEqual(self.sales_team_convert_m1.lead_month_count, 0)
self.assertEqual(self.sales_team_convert_m2.lead_month_count, 0)
def test_assign_configuration(self):
now_patch = datetime(2020, 11, 2, 10, 0, 0)
with patch.object(fields.Datetime, 'now', return_value=now_patch):
config = self.env['res.config.settings'].create({
'crm_use_auto_assignment': True,
'crm_auto_assignment_action': 'auto',
'crm_auto_assignment_interval_number': 19,
'crm_auto_assignment_interval_type': 'hours'
})
config._onchange_crm_auto_assignment_run_datetime()
config.execute()
self.assertTrue(self.assign_cron.active)
self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 2, 10, 0, 0) + relativedelta(hours=19))
config.write({
'crm_auto_assignment_interval_number': 2,
'crm_auto_assignment_interval_type': 'days'
})
config._onchange_crm_auto_assignment_run_datetime()
config.execute()
self.assertTrue(self.assign_cron.active)
self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 2, 10, 0, 0) + relativedelta(days=2))
config.write({
'crm_auto_assignment_run_datetime': fields.Datetime.to_string(datetime(2020, 11, 1, 10, 0, 0)),
})
config.execute()
self.assertTrue(self.assign_cron.active)
self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 1, 10, 0, 0))
config.write({
'crm_auto_assignment_action': 'manual',
})
config.execute()
self.assertFalse(self.assign_cron.active)
self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 1, 10, 0, 0))
config.write({
'crm_use_auto_assignment': False,
'crm_auto_assignment_action': 'auto',
})
config.execute()
self.assertFalse(self.assign_cron.active)
self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 1, 10, 0, 0))
def test_crm_team_assign_duplicates(self):
leads = self._create_leads_batch(
lead_type='lead',
user_ids=[False],
partner_ids=[self.contact_1.id, self.contact_2.id, False, False, False],
count=50
)
self.assertInitialData()
# assign probability to leads (bypass auto probability as purpose is not to test pls)
leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order
for idx in range(0, 5):
sliced_leads = leads[idx:len(leads):5]
for lead in sliced_leads:
lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2)
with self.with_user('user_sales_manager'):
with self.assertQueryCount(user_sales_manager=467): # crm only: 460
self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2)
# teams assign
leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order
leads_st1 = leads.filtered_domain([('team_id', '=', self.sales_team_1.id)])
leads_stc = leads.filtered_domain([('team_id', '=', self.sales_team_convert.id)])
self.assertEqual(len(leads_st1), 10) # 2 * 2 * 75 / 30.0
self.assertEqual(len(leads_stc), 12) # 2 * 2 * 90 / 30.0
# salespersons assign
self.members.invalidate_cache(fnames=['lead_month_count'])
self.assertMemberAssign(self.sales_team_1_m1, 3) # 45 max on 2 days
self.assertMemberAssign(self.sales_team_1_m2, 1) # 15 max on 2 days
self.assertMemberAssign(self.sales_team_1_m3, 1) # 15 max on 2 days
self.assertMemberAssign(self.sales_team_convert_m1, 2) # 30 max on 15
self.assertMemberAssign(self.sales_team_convert_m2, 4) # 60 max on 15
# run a second round to finish leads
with self.with_user('user_sales_manager'):
self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2)
# teams assign: everything should be done due to duplicates
leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order
self.assertTrue(len(leads.filtered_domain([('team_id', '=', False)])) == 0)
# deduplicate should have removed all duplicated linked to contact_1 and contact_2
new_assigned_leads_wpartner = self.env['crm.lead'].search([
('partner_id', 'in', (self.contact_1 | self.contact_2).ids),
('id', 'in', leads.ids)
])
self.assertEqual(len(new_assigned_leads_wpartner), 2)
def test_crm_team_assign_no_duplicates(self):
leads = self._create_leads_batch(
lead_type='lead',
user_ids=[False],
partner_ids=[False],
count=50
)
self.assertInitialData()
# assign probability to leads (bypass auto probability as purpose is not to test pls)
leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order
for idx in range(0, 5):
sliced_leads = leads[idx:len(leads):5]
for lead in sliced_leads:
lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2)
with self.with_user('user_sales_manager'):
with self.assertQueryCount(user_sales_manager=206): # crm only: 201
self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2)
# teams assign
leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order
leads_st1 = leads.filtered_domain([('team_id', '=', self.sales_team_1.id)])
leads_stc = leads.filtered_domain([('team_id', '=', self.sales_team_convert.id)])
self.assertEqual(len(leads_st1), 10) # 2 * 2 * 75 / 30.0
self.assertEqual(len(leads_stc), 12) # 2 * 2 * 90 / 30.0
# salespersons assign
self.members.invalidate_cache(fnames=['lead_month_count'])
self.assertMemberAssign(self.sales_team_1_m1, 3) # 45 max on 2 days
self.assertMemberAssign(self.sales_team_1_m2, 1) # 15 max on 2 days
self.assertMemberAssign(self.sales_team_1_m3, 1) # 15 max on 2 days
self.assertMemberAssign(self.sales_team_convert_m1, 2) # 30 max on 15
self.assertMemberAssign(self.sales_team_convert_m2, 4) # 60 max on 15
def test_crm_team_assign_populated(self):
""" Test assignment on a more high volume oriented test set in order to
have more insights on query counts. """
# create leads enough to have interesting counters
_lead_count, _email_dup_count, _partner_count = 500, 50, 150
leads = self._create_leads_batch(
lead_type='lead',
user_ids=[False],
partner_count=_partner_count,
country_ids=[self.env.ref('base.be').id, self.env.ref('base.fr').id, False],
count=_lead_count,
email_dup_count=_email_dup_count)
self.assertInitialData()
# assign for one month, aka a lot
self.env.ref('crm.ir_cron_crm_lead_assign').write({'interval_type': 'days', 'interval_number': 30})
self.env['ir.config_parameter'].set_param('crm.assignment.bundle', '20')
# create a third team
sales_team_3 = self.env['crm.team'].create({
'name': 'Sales Team 3',
'sequence': 15,
'alias_name': False,
'use_leads': True,
'use_opportunities': True,
'company_id': False,
'user_id': False,
'assignment_domain': [('country_id', '!=', False)],
})
sales_team_3_m1 = self.env['crm.team.member'].create({
'user_id': self.user_sales_manager.id,
'crm_team_id': sales_team_3.id,
'assignment_max': 60,
'assignment_domain': False,
})
sales_team_3_m2 = self.env['crm.team.member'].create({
'user_id': self.user_sales_leads.id,
'crm_team_id': sales_team_3.id,
'assignment_max': 60,
'assignment_domain': False,
})
sales_team_3_m3 = self.env['crm.team.member'].create({
'user_id': self.user_sales_salesman.id,
'crm_team_id': sales_team_3.id,
'assignment_max': 15,
'assignment_domain': [('probability', '>=', 10)],
})
sales_teams = self.sales_teams | sales_team_3
self.assertEqual(sum(team.assignment_max for team in sales_teams), 300)
self.assertEqual(len(leads), 550)
# assign probability to leads (bypass auto probability as purpose is not to test pls)
leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order
for idx in range(0, 5):
sliced_leads = leads[idx:len(leads):5]
for lead in sliced_leads:
lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2)
with self.with_user('user_sales_manager'):
with self.assertQueryCount(user_sales_manager=6289): # crm only: 6165
self.env['crm.team'].browse(sales_teams.ids)._action_assign_leads(work_days=30)
self.members.invalidate_cache(fnames=['lead_month_count'])
self.assertMemberAssign(self.sales_team_1_m1, 45) # 45 max on one month
self.assertMemberAssign(self.sales_team_1_m2, 15) # 15 max on one month
self.assertMemberAssign(self.sales_team_1_m3, 15) # 15 max on one month
self.assertMemberAssign(self.sales_team_convert_m1, 30) # 30 max on one month
self.assertMemberAssign(self.sales_team_convert_m2, 60) # 60 max on one month
self.assertMemberAssign(sales_team_3_m1, 60) # 60 max on one month
self.assertMemberAssign(sales_team_3_m2, 60) # 60 max on one month
self.assertMemberAssign(sales_team_3_m3, 15) # 15 max on one month
@@ -24,7 +24,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon):
with self.assertQueryCount(user_sales_manager=0):
test_leads = self.env['crm.lead'].browse(test_leads.ids)
with self.assertQueryCount(user_sales_manager=254): # crm only: 251
with self.assertQueryCount(user_sales_manager=254): # crm only: 252
test_leads._handle_salesmen_assignment(user_ids=user_ids, team_id=False)
self.assertEqual(test_leads.team_id, self.sales_team_convert | self.sales_team_1)
@@ -42,7 +42,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon):
with self.assertQueryCount(user_sales_manager=0):
test_leads = self.env['crm.lead'].browse(test_leads.ids)
with self.assertQueryCount(user_sales_manager=220): # crm only: 215
with self.assertQueryCount(user_sales_manager=220): # crm only: 218
test_leads._handle_salesmen_assignment(user_ids=user_ids, team_id=team_id)
self.assertEqual(test_leads.team_id, self.sales_team_convert)
@@ -166,7 +166,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon):
test_leads = self._create_leads_batch(count=50, user_ids=[False])
user_ids = self.assign_users.ids
with self.assertQueryCount(user_sales_manager=1363): # crm only: 1352
with self.assertQueryCount(user_sales_manager=1367): # crm only: 1357
mass_convert = self.env['crm.lead2opportunity.partner.mass'].with_context({
'active_model': 'crm.lead',
'active_ids': test_leads.ids,
+12 -3
View File
@@ -1,10 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo.addons.crm.tests.common import TestCrmCommon
from odoo.tests import HttpCase
from odoo.tests.common import tagged, users
@odoo.tests.tagged('post_install','-at_install')
class TestUi(odoo.tests.HttpCase):
@tagged('post_install', '-at_install')
class TestUi(HttpCase):
def test_01_crm_tour(self):
self.start_tour("/web", 'crm_tour', login="admin")
@@ -22,3 +24,10 @@ class TestUi(odoo.tests.HttpCase):
])]
})
self.start_tour("/web", 'crm_rainbowman', login="temp_crm_user")
class TestCRMLeadMisc(TestCrmCommon):
@users('user_sales_leads')
def test_team_my_pipeline(self):
action = self.env['crm.team'].action_your_pipeline()
+4
View File
@@ -7,7 +7,11 @@
<script type="text/javascript" src="/crm/static/src/js/systray_activity_menu.js"/>
<script type="text/javascript" src="/crm/static/src/js/tours/crm.js"></script>
</xpath>
<xpath expr="." position="inside">
<link rel="stylesheet" type="text/scss" href="/crm/static/src/scss/crm_team_member_views.scss"></link>
</xpath>
</template>
<template id="assets_tests" name="CRM Assets Tests" inherit_id="web.assets_tests">
<xpath expr="." position="inside">
<script type="text/javascript" src="/crm/static/tests/tours/crm_rainbowman.js"></script>
+10 -5
View File
@@ -213,10 +213,9 @@
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
</group>
<group>
<field name="user_id" domain="[('share', '=', False)]"
<field name="user_id"
context="{'default_sales_team_id': team_id}" widget="many2one_avatar_user"/>
<field name="team_id" widget="selection"
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]"/>
<field name="team_id" widget="selection"/>
<field name="type" invisible="1"/>
</group>
<group name="lead_priority" attrs="{'invisible': [('type', '=', 'opportunity')]}">
@@ -227,6 +226,7 @@
<field name="lost_reason" attrs="{'invisible': [('active', '=', True)]}"/>
<field name="date_conversion" invisible="1"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="user_company_ids" invisible="1"/>
</group>
</group>
@@ -338,7 +338,9 @@
<field name="state_id" optional="hide"/>
<field name="country_id" optional="show" options="{'no_open': True, 'no_create': True}"/>
<field name="partner_id" invisible="1"/>
<field name="user_id" optional="show" widget="many2one_avatar_user" domain="[('share', '=', False)]"/>
<!-- Explicit domain due to multi edit -> real company domain would be complicated -->
<field name="user_id" optional="show" widget="many2one_avatar_user"
domain="[('share', '=', False)]"/>
<field name="team_id" optional="show"/>
<field name="active" invisible="1"/>
<field name="probability" invisible="1"/>
@@ -443,6 +445,7 @@
<field name="company_currency" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="user_id" invisible="1"/>
<field name="user_company_ids" invisible="1"/>
<field name="team_id" invisible="1"/>
<field name="type" invisible="1"/>
<field name="partner_name" invisible="1"/>
@@ -669,7 +672,9 @@
<field name="city" optional="hide"/>
<field name="state_id" optional="hide"/>
<field name="country_id" optional="hide" options="{'no_open': True, 'no_create': True}"/>
<field name="user_id" widget="many2one_avatar_user" optional="show" domain="[('share', '=', False)]"/>
<!-- Explicit domain due to multi edit -> real company domain would be complicated -->
<field name="user_id" widget="many2one_avatar_user" optional="show"
domain="[('share', '=', False)]"/>
<field name="team_id" optional="hide"/>
<field name="priority" optional="hide"/>
<field name="activity_ids" widget="list_activity"/>
+9 -2
View File
@@ -35,7 +35,7 @@
id="sales_team_menu_team_pipeline"
name="Teams"
parent="crm_menu_sales"
action="sales_team.crm_team_salesteams_pipelines_act"
action="sales_team.crm_team_action_pipeline"
groups="sales_team.group_sale_manager"
sequence="4"/>
<menuitem
@@ -94,8 +94,15 @@
id="crm_team_config"
name="Sales Teams"
parent="crm_menu_config"
action="sales_team.sales_team_config_action"
action="sales_team.crm_team_action_config"
sequence="5"/>
<menuitem
id="crm_team_member_config"
name="Teams Members"
parent="crm_menu_config"
action="sales_team.crm_team_member_action"
sequence="6"
groups="base.group_no_one"/>
<menuitem
id="crm_team_menu_config_activity_types"
name="Activity Types"
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="crm_team_member_view_tree" model="ir.ui.view">
<field name="name">crm.team.member.view.tree</field>
<field name="model">crm.team.member</field>
<field name="inherit_id" ref="sales_team.crm_team_member_view_tree"/>
<field name="arch" type="xml">
<field name="user_id" position="after">
<field name="assignment_enabled" invisible="1"/>
<field name="assignment_max"/>
<field name="lead_month_count"/>
</field>
</field>
</record>
<record id="crm_team_member_view_kanban" model="ir.ui.view">
<field name="name">crm.team.member.view.kanban.inherit.crm</field>
<field name="model">crm.team.member</field>
<field name="inherit_id" ref="sales_team.crm_team_member_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('oe_kanban_details')]" position="after">
<field name="assignment_enabled" invisible="1"/>
<div class="o_member_assignment"
attrs="{'invisible': [('assignment_enabled', '=', False)]}">
<field name="assignment_max" invisible="1"/>
<field name="lead_month_count" widget="gauge"
options="{'max_field': 'assignment_max'}"
attrs="{'invisible': [('assignment_max', '=', 0)]}"/>
</div>
</xpath>
</field>
</record>
<record id="crm_team_member_view_form" model="ir.ui.view">
<field name="name">crm.team.member.view.form.inherit.crm</field>
<field name="model">crm.team.member</field>
<field name="inherit_id" ref="sales_team.crm_team_member_view_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='member_partner_info']" position="after">
<group name="group_assign" attrs="{'invisible': [('assignment_enabled', '=', False)]}">
<field name="assignment_enabled" invisible="1"/>
<label for="lead_month_count"/>
<div>
<field name="lead_month_count" class="oe_inline"/>
<span class="oe_inline"> / </span>
<field name="assignment_max" class="oe_inline"/>
<span class="oe_inline"> (max) </span>
</div>
<field name="assignment_domain" string="Domain" widget="domain"
options="{'model': 'crm.lead'}"
attrs="{'invisible': [('assignment_max', '=', 0)]}"/>
</group>
</xpath>
</field>
</record>
<record id="sales_team.crm_team_member_action" model="ir.actions.act_window">
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new salesman
</p><p>
Link salespersons to sales teams. Set their monthly lead capacity
and configure automatic lead assignment.
</p>
</field>
</record>
</data></odoo>
+37 -4
View File
@@ -106,6 +106,15 @@
<field name="inherit_id" ref="sales_team.crm_team_view_form"/>
<field name="priority">12</field>
<field name="arch" type="xml">
<xpath expr="//sheet" position="before">
<header>
<button name="action_assign_leads" type="object"
string="Assign Leads"
class="oe_highlight"
confirm="This will assign leads to all members. Do you want to proceed?"
attrs="{'invisible': ['|', '&amp;', ('use_leads', '=', False), ('use_opportunities', '=', False), ('assignment_enabled', '=', False)]}"/>
</header>
</xpath>
<xpath expr="//div[@name='options_active']" position="inside">
<div class="o_row">
<span name="opportunities">
@@ -137,6 +146,30 @@
string="Accept Emails From"
attrs="{'invisible': [('use_leads', '=', False), ('use_opportunities', '=', False)]}"/>
</xpath>
<xpath expr="//group[@name='right']" position="attributes">
<attribute name="string">Assignment Rules</attribute>
<attribute name="attrs">{'invisible': [('assignment_enabled', '=', False)]}</attribute>
</xpath>
<xpath expr="//group[@name='right']" position="inside">
<field name="assignment_enabled" invisible="1"/>
<field name="assignment_auto_enabled" invisible="1"/>
<field name="assignment_domain" widget="domain" string="Domain"
options="{'model': 'crm.lead', 'in_dialog': True}"
attrs="{'invisible': [('assignment_enabled', '=', False)]}"/>
<label for="lead_all_assigned_month_count" string="Assigned Leads Count"
attrs="{'invisible': [('assignment_enabled', '=', False)]}"/>
<div attrs="{'invisible': [('assignment_enabled', '=', False)]}">
<field name="lead_all_assigned_month_count" class="oe_inline"/> /
<field name="assignment_max" class="oe_inline"/>
</div>
<field name="assignment_optout" attrs="{'invisible': [('assignment_auto_enabled', '=', False)]}"/>
</xpath>
<xpath expr="//field[@name='member_ids']" position="attributes">
<attribute name="attrs">{'invisible': [('assignment_enabled', '=', True)]}</attribute>
</xpath>
<xpath expr="//field[@name='crm_team_member_ids']" position="attributes">
<attribute name="attrs">{'invisible': [('assignment_enabled', '=', False)]}</attribute>
</xpath>
</field>
</record>
@@ -172,14 +205,14 @@
<field name="search_view_id" ref="crm.view_crm_case_opportunities_filter"/>
</record>
<record id="sales_team.crm_team_salesteams_pipelines_act" model="ir.actions.act_window">
<record id="sales_team.crm_team_action_pipeline" model="ir.actions.act_window">
<field name="domain">[('use_opportunities', '=', True)]</field>
</record>
<record id="crm_team_salesteams_view_kanban" model="ir.ui.view">
<field name="name">crm.team.kanban</field>
<record id="crm_team_view_kanban_dashboard" model="ir.ui.view">
<field name="name">crm.team.view.kanban.dashboard.inherit.crm</field>
<field name="model">crm.team</field>
<field name="inherit_id" ref="sales_team.crm_team_salesteams_view_kanban"/>
<field name="inherit_id" ref="sales_team.crm_team_view_kanban_dashboard"/>
<field name="arch" type="xml">
<data>
<xpath expr="//templates" position="before">
+55 -1
View File
@@ -39,6 +39,19 @@
</div>
</div>
</div>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="is_membership_multi"/>
</div>
<div class="o_setting_right_pane">
<label for="is_membership_multi"/>
<div class="text-muted">
Assign salespersons into multiple Sales Teams.
</div>
</div>
</div>
</div>
<div class="row mt16 o_settings_container">
<div class="col-lg-6 o_setting_box"
title="This can be used to compute statistical probability to close a lead"
@@ -59,14 +72,55 @@
<field name="predictive_lead_scoring_start_date" class="oe_inline" required="1"/>
</div>
<div class="mt16" groups="base.group_erp_manager">
<div class="text-muted">
<div class="text-muted mb-3">
Use this button to update the probabilities of all leads. This can take up to several minutes depending on how many there are.
</div>
<button name="action_reset_lead_probabilities" type="object" string="Update Probabilities" class="btn-primary"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box"
title="This can be used to automatically assign leads to sales persons based on rules">
<div class="o_setting_left_pane">
<field name="crm_use_auto_assignment"/>
</div>
<div class="o_setting_right_pane">
<label for="crm_use_auto_assignment"/>
<a href="https://www.odoo.com/documentation/user/14.0/crm/track_leads/lead_scoring.html#assign-leads"
title="Assign Documentation" class="o_doc_link" target="_blank"></a>
<div class="text-muted">
<span>Periodically assign leads based on rules</span><br />
<span attrs="{'invisible': [('crm_use_auto_assignment', '=', False)]}">
All sales teams will use this setting by default unless
specified otherwise.
</span>
</div>
<div class="row mt16" attrs="{'invisible': [('crm_use_auto_assignment', '=', False)]}">
<label string="Running" for="crm_auto_assignment_action" class="col-lg-3 o_light_label"/>
<field name="crm_auto_assignment_action"
attrs="{'required': [('crm_use_auto_assignment', '=', True)]}"/>
<button name="action_crm_assign_leads" type="object" class="btn-link">
<i title="Update now" role="img" aria-label="Update now" class="fa fa-fw fa-refresh"></i>
</button>
</div>
<div class="row mt16" attrs="{'invisible': ['|', ('crm_use_auto_assignment', '=', False), ('crm_auto_assignment_action', '=', 'manual')]}">
<label string="Repeat every" for="crm_auto_assignment_interval_type" class="col-lg-3 o_light_label"/>
<field name="crm_auto_assignment_interval_number"
class="oe_inline mr-2"
attrs="{'required': [('crm_use_auto_assignment', '=', True), ('crm_auto_assignment_action', '=', 'auto')]}"/>
<field name="crm_auto_assignment_interval_type"
class="oe_inline"
attrs="{'required': [('crm_use_auto_assignment', '=', True), ('crm_auto_assignment_action', '=', 'auto')]}"/>
</div>
<div class="row" attrs="{'invisible': ['|', ('crm_use_auto_assignment', '=', False), ('crm_auto_assignment_action', '=', 'manual')]}">
<label string="Next Run" for="crm_auto_assignment_run_datetime" class="col-lg-3 o_light_label"/>
<field name="crm_auto_assignment_run_datetime"/>
</div>
</div>
</div>
</div>
<h2>Lead Generation</h2>
<div class="row mt16 o_settings_container" name="convert_visitor_setting_container">
<div class="col-lg-6 o_setting_box">
+1 -2
View File
@@ -101,8 +101,7 @@ class Lead2OpportunityPartner(models.TransientModel):
user = convert.user_id
if convert.team_id and user in convert.team_id.member_ids | convert.team_id.user_id:
continue
team_domain = []
team = self.env['crm.team']._get_default_team_id(user_id=user.id, domain=team_domain)
team = self.env['crm.team']._get_default_team_id(user_id=user.id, domain=None)
convert.team_id = team.id
@api.model
@@ -50,8 +50,7 @@ class Lead2OpportunityMassConvert(models.TransientModel):
user = convert.user_id or convert.user_ids and convert.user_ids[0] or self.env.user
if convert.team_id and user in convert.team_id.member_ids | convert.team_id.user_id:
continue
team_domain = []
team = self.env['crm.team']._get_default_team_id(user_id=user.id, domain=team_domain)
team = self.env['crm.team']._get_default_team_id(user_id=user.id, domain=None)
convert.team_id = team.id
@api.depends('lead_tomerge_ids')
@@ -104,10 +104,20 @@ class CRMLeadMiningRequest(models.Model):
for request in self:
request.lead_count = mapped_data.get(request.id, 0)
@api.depends('user_id')
@api.depends('user_id', 'lead_type')
def _compute_team_id(self):
for record in self:
record.team_id = record.user_id.sale_team_id
""" When changing the user, also set a team_id or restrict team id
to the ones user_id is member of. """
for mining in self:
# setting user as void should not trigger a new team computation
if not mining.user_id:
continue
user = mining.user_id
if mining.team_id and user in mining.team_id.member_ids | mining.team_id.user_id:
continue
team_domain = [('use_leads', '=', True)] if mining.lead_type == 'lead' else [('use_opportunities', '=', True)]
team = self.env['crm.team']._get_default_team_id(user_id=user.id, domain=team_domain)
mining.team_id = team.id
@api.onchange('lead_number')
def _onchange_lead_number(self):
+1 -1
View File
@@ -4,7 +4,7 @@
{
'name': 'pos_sale',
'version': '1.0',
'version': '1.1',
'category': 'Hidden',
'sequence': 6,
'summary': 'Link module between Point of Sale and Sales',
+3 -3
View File
@@ -30,10 +30,10 @@
</field>
</record>
<record id="crm_team_salesteams_view_kanban_inherit_pos_sale" model="ir.ui.view">
<field name="name">crm.team.kanban</field>
<record id="crm_team_view_kanban_dashboard" model="ir.ui.view">
<field name="name">crm.team.view.kanban.dashboard.inherit.pos</field>
<field name="model">crm.team</field>
<field name="inherit_id" ref="sales_team.crm_team_salesteams_view_kanban"/>
<field name="inherit_id" ref="sales_team.crm_team_view_kanban_dashboard"/>
<field name="groups_id" eval="[(4, ref('point_of_sale.group_pos_user'))]"/>
<field name="arch" type="xml">
<data>
+2 -1
View File
@@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Sales',
'version': '1.1',
'version': '1.2',
'category': 'Sales/Sales',
'summary': 'Sales internal machinery',
'description': """
+1 -1
View File
@@ -407,7 +407,7 @@ class SaleOrder(models.Model):
if self.user_id:
self.team_id = self.env['crm.team'].with_context(
default_team_id=self.team_id.id
)._get_default_team_id(user_id=self.user_id.id)
)._get_default_team_id(user_id=self.user_id.id, domain=None)
@api.onchange('partner_id')
def onchange_partner_id_warning(self):
+1 -1
View File
@@ -29,8 +29,8 @@ class TestSaleOrder(TestSaleCommon):
'email': 'team0user@example.com',
'login': 'team0user',
'name': 'User in Team 0',
'sale_team_id': cls.crm_team0.id
})
cls.crm_team0.sudo().write({'member_ids': [4, cls.user_in_team.id]})
cls.user_not_in_team = cls.env['res.users'].sudo().create({
'email': 'noteamuser@example.com',
'login': 'noteamuser',
+2 -2
View File
@@ -17,7 +17,7 @@
name="Sales Teams"
parent="sale_order_menu"
groups="sales_team.group_sale_manager"
action="sales_team.crm_team_salesteams_act"
action="sales_team.crm_team_action_sales"
sequence="3"/>
<menuitem id="res_partner_menu"
@@ -46,7 +46,7 @@
<menuitem id="sales_team_config"
name="Sales Teams"
parent="sale.menu_sale_config"
action="sales_team.sales_team_config_action"
action="sales_team.crm_team_action_config"
sequence="2"/>
<menuitem id="menu_sales_config"
+4 -4
View File
@@ -11,7 +11,7 @@
<field name="use_quotations"/><label for="use_quotations"/>
</div>
</xpath>
<xpath expr="//group[@name='right']" position="inside">
<xpath expr="//field[@name='company_id']" position="after">
<field name="invoiced_target" widget="monetary"/>
</xpath>
</field>
@@ -46,10 +46,10 @@
<field name="search_view_id" ref="sale_order_view_search_inherit_quotation"/>
</record>
<record id="crm_team_salesteams_view_kanban" model="ir.ui.view">
<field name="name">crm.team.kanban</field>
<record id="crm_team_view_kanban_dashboard" model="ir.ui.view">
<field name="name">crm.team.view.kanban.dashboard.inherit.sale</field>
<field name="model">crm.team</field>
<field name="inherit_id" ref="sales_team.crm_team_salesteams_view_kanban"/>
<field name="inherit_id" ref="sales_team.crm_team_view_kanban_dashboard"/>
<field name="arch" type="xml">
<data>
<xpath expr="//templates" position="before">
-5
View File
@@ -12,9 +12,4 @@
</data>
</field>
</record>
<record id="sales_team.crm_team_salesteams_act" model="ir.actions.act_window">
<field name="context">{'in_sales_app': True}</field>
</record>
</odoo>
+2 -1
View File
@@ -3,7 +3,7 @@
{
'name': 'Sales Teams',
'version': '1.0',
'version': '1.1',
'category': 'Sales/Sales',
'summary': 'Sales Teams',
'description': """
@@ -19,6 +19,7 @@ Using this application you can manage Sales Teams with CRM and/or Sales
'views/assets.xml',
'views/crm_tag_views.xml',
'views/crm_team_views.xml',
'views/crm_team_member_views.xml',
'views/mail_activity_views.xml',
'views/res_partner_views.xml',
],
+4 -1
View File
@@ -4,9 +4,12 @@
<record id="team_sales_department" model="crm.team">
<field name="name">Sales</field>
<field name="sequence">0</field>
<field name="member_ids" eval="[(4, ref('base.user_admin'))]"/>
<field name="company_id" eval="False"/>
</record>
<record id="crm_team_member_admin_sales" model="crm.team.member">
<field name="crm_team_id" ref="team_sales_department"/>
<field name="user_id" ref="base.user_admin"/>
</record>
<record id="salesteam_website_sales" model="crm.team">
<field name="name">Website</field>
+9 -11
View File
@@ -1,24 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- rename for demo-data -->
<record model="crm.team" id="team_sales_department">
<field name="name">Europe</field>
<field name="member_ids" eval="[(4, ref('base.user_demo'))]"/>
<field name="company_id" eval="False"/>
</record>
<record id="base.user_demo" model="res.users">
<record model="res.users" id="base.user_demo">
<field name="groups_id" eval="[(4,ref('sales_team.group_sale_salesman'))]"/>
</record>
<record id="team_sales_department" model="crm.team">
<field name="member_ids" eval="[(4, ref('base.user_demo'))]"/>
<record model="crm.team" id="team_sales_department">
<field name="name">Sales</field>
</record>
<record model="crm.team" id="crm_team_1">
<field name="name">America</field>
<field name="name">Pre-Sales</field>
<field name="company_id" eval="False"/>
<field name="member_ids" eval="[(4, ref('base.user_demo'))]"/>
</record>
<record id="crm_team_member_demo_team_1" model="crm.team.member">
<field name="user_id" ref="base.user_demo"/>
<field name="crm_team_id" ref="sales_team.crm_team_1"/>
</record>
</data>
</odoo>
+1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import crm_team_member
from . import crm_team
from . import crm_tag
from . import res_partner
+138 -12
View File
@@ -20,20 +20,63 @@ class CrmTeam(models.Model):
_check_company_auto = True
def _get_default_team_id(self, user_id=None, domain=None):
user_id = user_id or self.env.uid
user_salesteam_id = self.env['res.users'].browse(user_id).sale_team_id.id
# Avoid searching on member_ids (+1 query) when we may have the user salesteam already in cache.
team = self.env['crm.team'].search([
('company_id', 'in', [False, self.env.company.id]),
'|', ('user_id', '=', user_id), ('id', '=', user_salesteam_id),
], limit=1)
""" 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'))
return team or self.env['crm.team'].search(domain or [], limit=1)
# 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.")
@@ -45,10 +88,24 @@ class CrmTeam(models.Model):
related='company_id.currency_id', readonly=True)
user_id = fields.Many2one('res.users', string='Team Leader', check_company=True)
# memberships
member_ids = fields.One2many(
'res.users', 'sale_team_id', string='Channel Members',
check_company=True, domain=[('share', '=', False)],
help="Add members to automatically assign their documents to this sales team. You can only be member of one team.")
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(
@@ -60,6 +117,71 @@ class CrmTeam(models.Model):
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
@@ -93,6 +215,10 @@ class CrmTeam(models.Model):
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
+195
View File
@@ -0,0 +1,195 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, exceptions, fields, models, _
class CrmTeamMember(models.Model):
_name = 'crm.team.member'
_inherit = ['mail.thread']
_description = 'Sales Team Member'
_rec_name = 'user_id'
_order = 'create_date ASC'
_check_company_auto = True
crm_team_id = fields.Many2one(
'crm.team', string='Sales Team',
default=False, # TDE: temporary fix to activate depending computed fields
check_company=True, index=True, required=True)
user_id = fields.Many2one(
'res.users', string='Salesperson', # TDE FIXME check responsible field
check_company=True, index=True, ondelete='cascade', required=True,
domain="[('share', '=', False), ('id', 'not in', user_in_teams_ids), ('company_ids', 'in', user_company_ids)]")
user_in_teams_ids = fields.Many2many(
'res.users', compute='_compute_user_in_teams_ids',
help='UX: Give users not to add in the currently chosen team to avoid duplicates')
user_company_ids = fields.Many2many(
'res.company', compute='_compute_user_company_ids',
help='UX: Limit to team company or all if no company')
active = fields.Boolean(string='Active', default=True)
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_warning = fields.Text(compute='_compute_member_warning')
# salesman information
image_1920 = fields.Image("Image", related="user_id.image_1920", max_width=1920, max_height=1920)
image_128 = fields.Image("Image (128)", related="user_id.image_128", max_width=128, max_height=128)
name = fields.Char(string='Name', related='user_id.display_name', readonly=False)
email = fields.Char(string='Email', related='user_id.email')
phone = fields.Char(string='Phone', related='user_id.phone')
mobile = fields.Char(string='Mobile', related='user_id.mobile')
company_id = fields.Many2one('res.company', string='Company', related='user_id.company_id')
@api.constrains('crm_team_id', 'user_id', 'active')
def _constrains_membership(self):
# In mono membership mode: check crm_team_id / user_id is unique for active
# memberships. Inactive memberships can create duplicate pairs which is whyy
# we don't use a SQL constraint. Include "self" in search in case we use create
# multi with duplicated user / team pairs in it. Use an explicit active leaf
# in domain as we may have an active_test in context that would break computation
existing = self.env['crm.team.member'].search([
('crm_team_id', 'in', self.crm_team_id.ids),
('user_id', 'in', self.user_id.ids),
('active', '=', True)
])
duplicates = self.env['crm.team.member']
active_records = dict(
(membership.user_id.id, membership.crm_team_id.id)
for membership in self if membership.active
)
for membership in self:
potential = existing.filtered(lambda m: m.user_id == membership.user_id and \
m.crm_team_id == membership.crm_team_id and m.id != membership.id
)
if not potential or len(potential) > 1:
duplicates += potential
continue
if active_records.get(potential.user_id.id):
duplicates += potential
else:
active_records[potential.user_id.id] = potential.crm_team_id.id
if duplicates:
raise exceptions.ValidationError(
_("You are trying to create duplicate membership(s). We found that %(duplicates)s already exist(s).",
duplicates=", ".join("%s (%s)" % (m.user_id.name, m.crm_team_id.name) for m in duplicates)
))
@api.depends('crm_team_id', 'is_membership_multi', 'user_id')
@api.depends_context('default_crm_team_id')
def _compute_user_in_teams_ids(self):
""" Give users not to add in the currently chosen team to avoid duplicates.
In multi membership mode this field is empty as duplicates are allowed. """
if all(m.is_membership_multi for m in self):
member_user_ids = self.env['res.users']
elif self.ids:
member_user_ids = self.env['crm.team.member'].search([('id', 'not in', self.ids)]).user_id
else:
member_user_ids = self.env['crm.team.member'].search([]).user_id
for member in self:
if member_user_ids:
member.user_in_teams_ids = member_user_ids
elif member.crm_team_id:
member.user_in_teams_ids = member.crm_team_id.member_ids
elif self.env.context.get('default_crm_team_id'):
member.user_in_teams_ids = self.env['crm.team'].browse(self.env.context['default_crm_team_id']).member_ids
else:
member.user_in_teams_ids = self.env['res.users']
@api.depends('crm_team_id')
def _compute_user_company_ids(self):
all_companies = self.env['res.company'].search([])
for member in self:
member.user_company_ids = member.crm_team_id.company_id or all_companies
@api.depends('crm_team_id')
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('is_membership_multi', 'active', 'user_id', 'crm_team_id')
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. """
if all(m.is_membership_multi for m in self):
self.member_warning = False
else:
active = self.filtered('active')
(self - active).member_warning = False
if not active:
return
existing = self.env['crm.team.member'].search([('user_id', 'in', active.user_id.ids)])
user_mapping = dict.fromkeys(existing.user_id, self.env['crm.team'])
for membership in existing:
user_mapping[membership.user_id] |= membership.crm_team_id
for member in active:
teams = user_mapping.get(member.user_id, self.env['crm.team'])
remaining = teams - (member.crm_team_id | member._origin.crm_team_id)
if remaining:
member.member_warning = _("Adding %(user_name)s in this team would remove him/her from its current teams %(team_names)s.",
user_name=member.user_id.name,
team_names=", ".join(remaining.mapped('name'))
)
else:
member.member_warning = False
@api.model_create_multi
def create(self, values_list):
""" Specific behavior implemented on create
* mono membership mode: other user memberships are automatically
archived (a warning already told it in form view);
* creating a membership already existing as archived: do nothing as
people can manage them from specific menu "Members";
"""
is_membership_multi = self.env['ir.config_parameter'].sudo().get_param('sales_team.membership_multi', False)
if not is_membership_multi:
self._synchronize_memberships(values_list)
return super(CrmTeamMember, self).create(values_list)
def write(self, values):
""" Specific behavior about active. If you change user_id / team_id user
get warnings in form view and a raise in constraint check. We support
archive / activation of memberships that toggles other memberships. But
we do not support manual creation or update of user_id / team_id. This
either works, either crashes). Indeed supporting it would lead to complex
code with low added value. Users should create or remove members, and
maybe archive / activate them. Updating manually memberships by
modifying user_id or team_id is advanced and does not benefit from our
support. """
is_membership_multi = self.env['ir.config_parameter'].sudo().get_param('sales_team.membership_multi', False)
if not is_membership_multi and values.get('active'):
self._synchronize_memberships([
dict(user_id=membership.user_id.id, crm_team_id=membership.crm_team_id.id)
for membership in self
])
return super(CrmTeamMember, self).write(values)
def _synchronize_memberships(self, user_team_ids):
""" Synchronize memberships: archive other memberships.
:param user_team_ids: list of pairs (user_id, crm_team_id)
"""
existing = self.search([
('active', '=', True), # explicit search on active only, whatever context
('user_id', 'in', [values['user_id'] for values in user_team_ids])
])
user_memberships = dict.fromkeys(existing.user_id.ids, self.env['crm.team.member'])
for membership in existing:
user_memberships[membership.user_id.id] += membership
existing_to_archive = self.env['crm.team.member']
for values in user_team_ids:
existing_to_archive += user_memberships.get(values['user_id'], self.env['crm.team.member']).filtered(
lambda m: m.crm_team_id.id != values['crm_team_id']
)
if existing_to_archive:
existing_to_archive.action_archive()
return existing_to_archive
+26 -3
View File
@@ -1,12 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import api, fields, models
class ResUsers(models.Model):
_inherit = 'res.users'
crm_team_ids = fields.Many2many(
'crm.team', 'crm_team_member', 'user_id', 'crm_team_id', string='Sales Teams',
check_company=True, copy=False, readonly=True,
compute='_compute_crm_team_ids', search='_search_crm_team_ids')
crm_team_member_ids = fields.One2many('crm.team.member', 'user_id', string='Sales Team Members')
sale_team_id = fields.Many2one(
'crm.team', "User's Sales Team",
help='Sales Team the user is member of. Used to compute the members of a Sales Team through the inverse one2many')
'crm.team', string='User Sales Team', compute='_compute_sale_team_id',
readonly=True, store=True,
help="Main user sales team. Used notably for pipeline, or to set sales team in invoicing or subscription.")
@api.depends('crm_team_member_ids.active')
def _compute_crm_team_ids(self):
for user in self:
user.crm_team_ids = user.crm_team_member_ids.crm_team_id
def _search_crm_team_ids(self, operator, value):
return [('crm_team_member_ids.crm_team_id', operator, value)]
@api.depends('crm_team_member_ids.crm_team_id', 'crm_team_member_ids.create_date', 'crm_team_member_ids.active')
def _compute_sale_team_id(self):
for user in self:
if not user.crm_team_member_ids.ids:
user.sale_team_id = False
else:
sorted_memberships = user.crm_team_member_ids # sorted by create date
user.sale_team_id = sorted_memberships[0].crm_team_id if sorted_memberships else False
@@ -2,6 +2,9 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_crm_team,crm.team,model_crm_team,base.group_user,1,0,0,0
access_crm_team_user,crm.team.user,model_crm_team,sales_team.group_sale_salesman,1,0,0,0
access_crm_team_manager,crm.team.manager,model_crm_team,sales_team.group_sale_manager,1,1,1,1
access_crm_team_member_all,access.crm.team.member.all,model_crm_team_member,,0,0,0,0
access_crm_team_member_user,access.crm.team.member.user,model_crm_team_member,base.group_user,1,0,0,0
access_crm_team_member_manager,access.crm.team.member.manager,model_crm_team_member,sales_team.group_sale_manager,1,1,1,1
access_crm_tag,crm_tag,model_crm_tag,base.group_user,0,0,0,0
access_crm_tag_salesman,crm_tag salesman,model_crm_tag,sales_team.group_sale_salesman,1,1,1,0
access_crm_tag_manager,crm_tag manager,model_crm_tag,sales_team.group_sale_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_crm_team crm.team model_crm_team base.group_user 1 0 0 0
3 access_crm_team_user crm.team.user model_crm_team sales_team.group_sale_salesman 1 0 0 0
4 access_crm_team_manager crm.team.manager model_crm_team sales_team.group_sale_manager 1 1 1 1
5 access_crm_team_member_all access.crm.team.member.all model_crm_team_member 0 0 0 0
6 access_crm_team_member_user access.crm.team.member.user model_crm_team_member base.group_user 1 0 0 0
7 access_crm_team_member_manager access.crm.team.member.manager model_crm_team_member sales_team.group_sale_manager 1 1 1 1
8 access_crm_tag crm_tag model_crm_tag base.group_user 0 0 0 0
9 access_crm_tag_salesman crm_tag salesman model_crm_tag sales_team.group_sale_salesman 1 1 1 0
10 access_crm_tag_manager crm_tag manager model_crm_tag sales_team.group_sale_manager 1 1 1 1
@@ -0,0 +1,40 @@
$o-kanban-large-record-width: 400px;
$o-kanban-medium-record-width: 350px;
.o_kanban_view.o_crm_team_kanban {
.o_kanban_group:not(.o_column_folded) {
width: $o-kanban-large-record-width + 2*$o-kanban-group-padding;
}
.o_kanban_record {
width: $o-kanban-large-record-width;
}
}
.o_kanban_view.o_crm_team_member_kanban {
.o_kanban_group:not(.o_column_folded) {
width: $o-kanban-medium-record-width + 2*$o-kanban-group-padding;
}
.o_kanban_record {
width: $o-kanban-medium-record-width;
}
.ribbon {
&::before, &::after {
display: none;
}
span {
background-color: $o-brand-odoo;
padding: 5px;
font-size: small;
height: auto;
}
}
.ribbon-top-right {
margin-top: -$o-kanban-dashboard-vpadding;
span {
left: 0px;
right: 30px;
}
}
}
@@ -1,8 +0,0 @@
.o_kanban_view.o_kanban_dashboard.o_salesteam_kanban {
&.o_kanban_ungrouped {
.o_kanban_record {
width: 450px;
}
}
}
+2
View File
@@ -3,3 +3,5 @@
from . import common
from . import test_sales_team
from . import test_sales_team_internals
from . import test_sales_team_membership
+28 -1
View File
@@ -10,6 +10,7 @@ class TestSalesCommon(TransactionCase):
@classmethod
def setUpClass(cls):
super(TestSalesCommon, cls).setUpClass()
cls.env['ir.config_parameter'].set_param('sales_team.membership_multi', False)
# Salesmen organization
# ------------------------------------------------------------
@@ -27,6 +28,7 @@ class TestSalesCommon(TransactionCase):
# data----------------9999-----??
cls.company_main = cls.env.user.company_id
cls.user_admin = cls.env.ref('base.user_admin')
cls.user_sales_manager = mail_new_test_user(
cls.env, login='user_sales_manager',
name='Martin Sales Manager', email='crm_manager@test.example.com',
@@ -56,7 +58,14 @@ class TestSalesCommon(TransactionCase):
'sequence': 5,
'company_id': False,
'user_id': cls.user_sales_manager.id,
'member_ids': [(4, cls.user_sales_leads.id), (4, cls.user_admin.id)],
})
cls.sales_team_1_m1 = cls.env['crm.team.member'].create({
'user_id': cls.user_sales_leads.id,
'crm_team_id': cls.sales_team_1.id,
})
cls.sales_team_1_m2 = cls.env['crm.team.member'].create({
'user_id': cls.user_admin.id,
'crm_team_id': cls.sales_team_1.id,
})
@@ -65,6 +74,12 @@ class TestSalesMC(TestSalesCommon):
@classmethod
def setUpClass(cls):
""" Teams / Company
* sales_team_1: False
* team_c2: company_2
* team_mc: company_main
"""
super(TestSalesMC, cls).setUpClass()
cls.company_2 = cls.env['res.company'].create({
'name': 'New Test Company',
@@ -74,5 +89,17 @@ class TestSalesMC(TestSalesCommon):
cls.team_c2 = cls.env['crm.team'].create({
'name': 'C2 Team1',
'sequence': 1,
'user_id': False,
'company_id': cls.company_2.id,
})
cls.team_mc = cls.env['crm.team'].create({
'name': 'MainCompany Team',
'user_id': cls.user_admin.id,
'sequence': 3,
'company_id': cls.company_main.id
})
# admin and sale manager belong to new company also
(cls.user_admin | cls.user_sales_manager).write({
'company_ids': [(4, cls.company_2.id)]
})
+61 -1
View File
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sales_team.tests.common import TestSalesCommon
from odoo import exceptions
from odoo.addons.sales_team.tests.common import TestSalesCommon, TestSalesMC
from odoo.tests.common import users
class TestDefaultTeam(TestSalesCommon):
@@ -47,6 +49,7 @@ class TestDefaultTeam(TestSalesCommon):
def test_default_team_fallback(self):
""" Test fallback: domain, order """
self.sales_team_1.member_ids = [(5,)]
self.sales_team_1.flush()
with self.with_user('user_sales_leads'):
team = self.env['crm.team']._get_default_team_id()
@@ -67,3 +70,60 @@ class TestDefaultTeam(TestSalesCommon):
with self.with_user('user_sales_leads'):
team = self.env['crm.team']._get_default_team_id()
self.assertEqual(team, self.team_c2)
class TestMultiCompany(TestSalesMC):
"""Tests to check multi company management with sales team and their
members. """
@users('user_sales_manager')
def test_team_members(self):
""" Test update of team users involving company check """
team_c2 = self.env['crm.team'].browse(self.team_c2.id)
team_c2.write({'name': 'Manager Update'})
self.assertEqual(team_c2.member_ids, self.env['res.users'])
# can add someone from same company
self.env.user.write({'company_id': self.company_2.id})
team_c2.write({'member_ids': [(4, self.env.user.id)]})
self.assertEqual(team_c2.member_ids, self.env.user)
# cannot add someone from another company
with self.assertRaises(exceptions.UserError):
team_c2.write({'member_ids': [(4, self.user_sales_salesman.id)]})
# reset members, change company
team_c2.write({'member_ids': [(5, 0)], 'company_id': self.company_main.id})
self.assertEqual(team_c2.member_ids, self.env['res.users'])
team_c2.write({'member_ids': [(4, self.user_sales_salesman.id)]})
self.assertEqual(team_c2.member_ids, self.user_sales_salesman)
# cannot change company as it breaks memberships mc check
with self.assertRaises(exceptions.UserError):
team_c2.write({'company_id': self.company_2.id})
@users('user_sales_manager')
def test_team_memberships(self):
""" Test update of team member involving company check """
team_c2 = self.env['crm.team'].browse(self.team_c2.id)
team_c2.write({'name': 'Manager Update'})
self.assertEqual(team_c2.member_ids, self.env['res.users'])
# can add someone from same company
self.env.user.write({'company_id': self.company_2.id})
team_c2.write({'crm_team_member_ids': [(0, 0, {'user_id': self.env.user.id})]})
self.assertEqual(team_c2.member_ids, self.env.user)
# cannot add someone from another company
with self.assertRaises(exceptions.UserError):
team_c2.write({'crm_team_member_ids': [(0, 0, {'user_id': self.user_sales_salesman.id})]})
# reset members, change company
team_c2.write({'member_ids': [(5, 0)], 'company_id': self.company_main.id})
self.assertEqual(team_c2.member_ids, self.env['res.users'])
team_c2.write({'crm_team_member_ids': [(0, 0, {'user_id': self.user_sales_salesman.id})]})
self.assertEqual(team_c2.member_ids, self.user_sales_salesman)
# cannot change company as it breaks memberships mc check
with self.assertRaises(exceptions.UserError):
team_c2.write({'company_id': self.company_2.id})
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import exceptions
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.sales_team.tests.common import TestSalesMC
from odoo.tests.common import users, TransactionCase
from odoo.tools import mute_logger
class TestCornerCases(TransactionCase):
def setUp(self):
super(TestCornerCases, self).setUp()
self.user_sales_leads = mail_new_test_user(
self.env, login='user_sales_leads',
name='Laetitia Sales Leads', email='crm_leads@test.example.com',
company_id=self.env.user.company_id.id,
notification_type='inbox',
groups='sales_team.group_sale_salesman_all_leads,base.group_partner_manager',
)
self.sales_team_1 = self.env['crm.team'].create({
'name': 'Test Sales Team',
'sequence': 5,
'company_id': False,
'user_id': self.env.user.id,
})
def test_unicity(self):
""" Archived memberships should be removed when detecting duplicates.
Creating duplicates should raise unicity constraint.
Note: redoing the data set to avoid clashing with SavepointCase as
we expect a db-level assert """
sales_team_1_m1 = self.env['crm.team.member'].create({
'user_id': self.user_sales_leads.id,
'crm_team_id': self.sales_team_1.id,
})
sales_team_1_m1.write({'active': False})
sales_team_1_m1.flush()
sales_team_1_m2 = self.env['crm.team.member'].create({
'user_id': self.user_sales_leads.id,
'crm_team_id': self.sales_team_1.id,
})
found = self.env['crm.team.member'].search([
('user_id', '=', self.user_sales_leads.id),
('crm_team_id', '=', self.sales_team_1.id),
])
self.assertEqual(found, sales_team_1_m2)
with self.assertRaises(exceptions.UserError), mute_logger('odoo.sql_db'):
self.env['crm.team.member'].create({
'user_id': self.user_sales_leads.id,
'crm_team_id': self.sales_team_1.id,
})
def test_unicity_multicreate(self):
""" Test constraint works with creating duplicates in the same create
method. """
with self.assertRaises(exceptions.UserError), mute_logger('odoo.sql_db'):
self.env['crm.team.member'].create([
{'user_id': self.user_sales_leads.id, 'crm_team_id': self.sales_team_1.id},
{'user_id': self.user_sales_leads.id, 'crm_team_id': self.sales_team_1.id}
])
class TestSecurity(TestSalesMC):
@users('user_sales_leads')
def test_team_access(self):
sales_team = self.sales_team_1.with_user(self.env.user)
sales_team.read(['name'])
for member in sales_team.member_ids:
member.read(['name'])
with self.assertRaises(exceptions.AccessError):
sales_team.write({'name': 'Trolling'})
for membership in sales_team.crm_team_member_ids:
membership.read(['name'])
with self.assertRaises(exceptions.AccessError):
membership.write({'active': False})
with self.assertRaises(exceptions.AccessError):
sales_team.write({'member_ids': [(5, 0)]})
@users('user_sales_leads')
def test_team_multi_company(self):
self.sales_team_1.with_user(self.env.user).read(['name'])
with self.assertRaises(exceptions.AccessError):
self.team_c2.with_user(self.env.user).read(['name'])
@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import exceptions
from odoo.addons.sales_team.tests.common import TestSalesCommon
from odoo.tests.common import users
from odoo.tools import mute_logger
class TestMembership(TestSalesCommon):
"""Tests to ensure membership behavior """
@classmethod
def setUpClass(cls):
super(TestMembership, cls).setUpClass()
cls.new_team = cls.env['crm.team'].create({
'name': 'Test Specific',
'sequence': 10,
})
cls.env['ir.config_parameter'].set_param('sales_team.membership_multi', True)
@users('user_sales_manager')
def test_fields(self):
self.assertTrue(self.sales_team_1.with_user(self.env.user).is_membership_multi)
self.assertTrue(self.new_team.with_user(self.env.user).is_membership_multi)
self.env['ir.config_parameter'].sudo().set_param('sales_team.membership_multi', False)
self.assertFalse(self.sales_team_1.with_user(self.env.user).is_membership_multi)
self.assertFalse(self.new_team.with_user(self.env.user).is_membership_multi)
@users('user_sales_manager')
def test_members_mono(self):
""" Test mono mode using the user m2m relationship """
self.env['ir.config_parameter'].sudo().set_param('sales_team.membership_multi', False)
# ensure initial data
sales_team_1 = self.sales_team_1.with_user(self.env.user)
new_team = self.new_team.with_user(self.env.user)
self.assertEqual(sales_team_1.member_ids, self.user_sales_leads | self.user_admin)
# test various add / remove on computed m2m
self.assertEqual(new_team.member_ids, self.env['res.users'])
new_team.write({'member_ids': [(4, self.env.uid)]})
self.assertEqual(new_team.member_ids, self.env.user)
new_team.write({'member_ids': [(4, self.user_sales_leads.id)]})
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads)
new_team.write({'member_ids': [(3, self.user_sales_leads.id)]})
self.assertEqual(new_team.member_ids, self.env.user)
new_team.write({'member_ids': [(6, 0, (self.user_sales_leads | self.env.user).ids)]})
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads)
# archived memberships on sales_team_1 for user_sales_leads
self.assertEqual(sales_team_1.member_ids, self.user_admin)
# create a new user on the fly, just for testing
self.user_sales_manager.write({'groups_id': [(4, self.env.ref('base.group_system').id)]})
new_team.write({'member_ids': [(0, 0, {
'name': 'Marty OnTheMCFly',
'login': 'mcfly@test.example.com',
})]})
new_user = self.env['res.users'].search([('login', '=', 'mcfly@test.example.com')])
self.assertTrue(len(new_user))
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads | new_user)
self.user_sales_manager.write({'groups_id': [(3, self.env.ref('base.group_system').id)]})
new_team.flush()
memberships = self.env['crm.team.member'].with_context(active_test=False).search([('user_id', '=', self.user_sales_leads.id)])
self.assertEqual(len(memberships), 3) # subscribed twice to new_team + subscribed to sales_team_1
self.assertEqual(memberships.crm_team_id, sales_team_1 | new_team)
self.assertFalse(memberships.filtered(lambda m: m.crm_team_id == sales_team_1).active)
new_team_memberships = memberships.filtered(lambda m: m.crm_team_id == new_team)
self.assertEqual(len(new_team_memberships), 2) # subscribed, removed, then subscribed again
self.assertTrue(set(new_team_memberships.mapped('active')), set([False, True]))
# still avoid duplicated team / user entries
with self.assertRaises(exceptions.UserError):
self.env['crm.team.member'].create({'crm_team_id': new_team.id, 'user_id': new_user.id})
@users('user_sales_manager')
def test_members_multi(self):
# ensure initial data
sales_team_1 = self.sales_team_1.with_user(self.env.user)
new_team = self.new_team.with_user(self.env.user)
self.assertEqual(sales_team_1.member_ids, self.user_sales_leads | self.user_admin)
# test various add / remove on computed m2m
self.assertEqual(new_team.member_ids, self.env['res.users'])
new_team.write({'member_ids': [(4, self.env.uid), (4, self.user_sales_leads.id)]})
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads)
new_team.write({'member_ids': [(3, self.user_sales_leads.id)]})
self.assertEqual(new_team.member_ids, self.env.user)
new_team.write({'member_ids': [(6, 0, (self.user_sales_leads | self.env.user).ids)]})
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads)
# nothing changed on sales_team_1
self.assertEqual(sales_team_1.member_ids, self.user_sales_leads | self.user_admin)
# create a new user on the fly, just for testing
self.user_sales_manager.write({'groups_id': [(4, self.env.ref('base.group_system').id)]})
new_team.write({'member_ids': [(0, 0, {
'name': 'Marty OnTheMCFly',
'login': 'mcfly@test.example.com',
})]})
new_user = self.env['res.users'].search([('login', '=', 'mcfly@test.example.com')])
self.assertTrue(len(new_user))
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads | new_user)
self.user_sales_manager.write({'groups_id': [(3, self.env.ref('base.group_system').id)]})
new_team.flush()
# still avoid duplicated team / user entries
with self.assertRaises(exceptions.UserError):
self.env['crm.team.member'].create({'crm_team_id': new_team.id, 'user_id': new_user.id})
@users('user_sales_manager')
def test_memberships_mono(self):
""" Test mono mode: updating crm_team_member_ids field """
self.env['ir.config_parameter'].sudo().set_param('sales_team.membership_multi', False)
# ensure initial data
sales_team_1 = self.env['crm.team'].browse(self.sales_team_1.ids)
new_team = self.env['crm.team'].browse(self.new_team.ids)
self.assertEqual(sales_team_1.member_ids, self.user_sales_leads | self.user_admin)
# subscribe on new team (user_sales_leads will have two memberships -> old one deactivated)
self.assertEqual(new_team.member_ids, self.env['res.users'])
new_team.write({'crm_team_member_ids': [
(0, 0, {'user_id': self.user_sales_leads.id}),
(0, 0, {'user_id': self.uid}),
]})
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads)
self.assertEqual(sales_team_1.member_ids, self.user_admin)
new_team.flush()
memberships = self.env['crm.team.member'].with_context(active_test=False).search([('user_id', '=', self.user_sales_leads.id)])
self.assertEqual(memberships.crm_team_id, sales_team_1 | new_team)
self.assertFalse(memberships.filtered(lambda m: m.crm_team_id == sales_team_1).active)
self.assertTrue(memberships.filtered(lambda m: m.crm_team_id == new_team).active)
# subscribe user_sales_leads on old team -> old membership still archived and kept
sales_team_1.write({'crm_team_member_ids': [(0, 0, {'user_id': self.user_sales_leads.id})]})
memberships_new = self.env['crm.team.member'].with_context(active_test=False).search([('user_id', '=', self.user_sales_leads.id)])
self.assertTrue(memberships < memberships_new)
self.assertEqual(memberships.crm_team_id, sales_team_1 | new_team)
# old membership is still inactive, new membership is active
old_st_1 = memberships_new.filtered(lambda m: m.crm_team_id == sales_team_1 and m in memberships)
new_st_1 = memberships_new.filtered(lambda m: m.crm_team_id == sales_team_1 and m not in memberships)
new_nt = memberships_new.filtered(lambda m: m.crm_team_id == new_team)
self.assertFalse(old_st_1.active)
self.assertTrue(new_st_1.active)
self.assertFalse(new_nt.active)
# check members fields
self.assertEqual(new_team.member_ids, self.env.user)
self.assertEqual(sales_team_1.member_ids, self.user_admin | self.user_sales_leads)
# activate another team membership: previous team membership should be de activated
new_nt.toggle_active()
self.assertTrue(new_nt.active)
self.assertFalse(old_st_1.active)
self.assertFalse(new_st_1.active)
# activate another team membership: previous team membership should be de activated
old_st_1.toggle_active()
self.assertFalse(new_nt.active)
self.assertTrue(old_st_1.active)
self.assertFalse(new_st_1.active)
# try to activate duplicate memberships again, which should trigger issues
with self.assertRaises(exceptions.UserError):
new_st_1.toggle_active()
@users('user_sales_manager')
def test_memberships_multi(self):
# ensure initial data
sales_team_1 = self.env['crm.team'].browse(self.sales_team_1.ids)
new_team = self.env['crm.team'].browse(self.new_team.ids)
self.assertEqual(sales_team_1.member_ids, self.user_sales_leads | self.user_admin)
# subscribe on new team (user_sales_leads will have two memberships -> old one deactivated)
self.assertEqual(new_team.member_ids, self.env['res.users'])
new_team.write({'crm_team_member_ids': [
(0, 0, {'user_id': self.user_sales_leads.id}),
(0, 0, {'user_id': self.uid}),
]})
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads)
self.assertEqual(sales_team_1.member_ids, self.user_sales_leads | self.user_admin)
new_team.flush()
memberships = self.env['crm.team.member'].with_context(active_test=False).search([('user_id', '=', self.user_sales_leads.id)])
self.assertEqual(memberships.crm_team_id, sales_team_1 | new_team)
self.assertTrue(memberships.filtered(lambda m: m.crm_team_id == sales_team_1).active)
self.assertTrue(memberships.filtered(lambda m: m.crm_team_id == new_team).active)
# archive membership on sales_team_1 and try creating a new one
memberships.filtered(lambda m: m.crm_team_id == sales_team_1).write({'active': False})
# subscribe user_sales_leads on old team -> old membership still archived and kept
sales_team_1.write({'crm_team_member_ids': [(0, 0, {'user_id': self.user_sales_leads.id})]})
memberships_new = self.env['crm.team.member'].with_context(active_test=False).search([('user_id', '=', self.user_sales_leads.id)])
self.assertTrue(memberships < memberships_new)
self.assertEqual(memberships.crm_team_id, sales_team_1 | new_team)
# old membership is still inactive, new membership is active
old_st_1 = memberships_new.filtered(lambda m: m.crm_team_id == sales_team_1 and m in memberships)
new_st_1 = memberships_new.filtered(lambda m: m.crm_team_id == sales_team_1 and m not in memberships)
new_nt = memberships_new.filtered(lambda m: m.crm_team_id == new_team)
self.assertFalse(old_st_1.active)
self.assertTrue(new_st_1.active)
self.assertTrue(new_nt.active)
# check members fields
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads)
self.assertEqual(sales_team_1.member_ids, self.user_admin | self.user_sales_leads)
# try to activate duplicate memberships again, which should trigger issues
with self.assertRaises(exceptions.UserError):
old_st_1.toggle_active()
@users('user_sales_manager')
def test_memberships_sync(self):
sales_team_1 = self.env['crm.team'].browse(self.sales_team_1.ids)
new_team = self.env['crm.team'].browse(self.new_team.ids)
self.assertEqual(sales_team_1.member_ids, self.user_sales_leads | self.user_admin)
self.assertEqual(new_team.crm_team_member_ids, self.env['crm.team.member'])
self.assertEqual(new_team.crm_team_member_all_ids, self.env['crm.team.member'])
self.assertEqual(new_team.member_ids, self.env['res.users'])
# creating memberships correctly updates m2m without any refresh
new_member = self.env['crm.team.member'].create({
'user_id': self.env.user.id,
'crm_team_id': self.new_team.id,
})
self.assertEqual(new_team.crm_team_member_ids, new_member)
self.assertEqual(new_team.crm_team_member_all_ids, new_member)
self.assertEqual(new_team.member_ids, self.env.user)
# adding members correctly update o2m with right values
new_team.write({
'member_ids': [(4, self.user_sales_leads.id)]
})
added = self.env['crm.team.member'].search([('crm_team_id', '=', new_team.id), ('user_id', '=', self.user_sales_leads.id)])
self.assertEqual(new_team.crm_team_member_ids, new_member + added)
self.assertEqual(new_team.crm_team_member_all_ids, new_member + added)
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads)
# archiving membership correctly updates m2m and o2m
added.write({'active': False})
self.assertEqual(new_team.crm_team_member_ids, new_member)
self.assertEqual(new_team.crm_team_member_all_ids, new_member + added)
self.assertEqual(new_team.member_ids, self.env.user)
# reactivating correctly updates m2m and o2m
added.write({'active': True})
self.assertEqual(new_team.crm_team_member_ids, new_member + added)
self.assertEqual(new_team.crm_team_member_all_ids, new_member + added)
self.assertEqual(new_team.member_ids, self.env.user | self.user_sales_leads)
# archived are kept if duplicated on write
admin_original = self.env['crm.team.member'].search([
('crm_team_id', '=', sales_team_1.id),
('user_id', '=', self.user_admin.id)
])
self.assertTrue(bool(admin_original))
admin_archived = self.env['crm.team.member'].create({
'crm_team_id': new_team.id,
'user_id': self.user_admin.id,
'active': False,
})
admin_original.write({'crm_team_id': new_team.id})
# send to db as errors may pop at that step (like trying to set NULL on a m2o inverse of o2m)
self.new_team.flush()
self.assertTrue(self.user_admin in new_team.member_ids)
self.assertTrue(admin_original.active)
self.assertTrue(admin_archived.exists())
self.assertFalse(admin_archived.active)
# change team of membership should raise unicity constraint
with self.assertRaises(exceptions.UserError), mute_logger('odoo.sql_db'):
added.write({'crm_team_id': sales_team_1.id})
self.new_team.flush()
def test_users_sale_team_id(self):
self.assertTrue(self.sales_team_1.sequence < self.new_team.sequence)
self.assertEqual(self.user_sales_leads.crm_team_ids, self.sales_team_1)
self.assertEqual(self.user_sales_leads.sale_team_id, self.sales_team_1)
# subscribe to new team -> default team is still the old one
self.new_team.write({
'member_ids': [(4, self.user_sales_leads.id)]
})
self.assertEqual(self.user_sales_leads.crm_team_ids, self.sales_team_1 | self.new_team)
self.assertEqual(self.user_sales_leads.sale_team_id, self.sales_team_1)
# archive membership to first team -> second one becomes default
self.sales_team_1_m1.write({'active': False})
self.assertEqual(self.user_sales_leads.crm_team_ids, self.new_team)
self.assertEqual(self.user_sales_leads.sale_team_id, self.new_team)
# activate membership to first team -> first one becomes default again
self.sales_team_1_m1.write({'active': True})
self.assertEqual(self.user_sales_leads.crm_team_ids, self.sales_team_1 | self.new_team)
self.assertEqual(self.user_sales_leads.sale_team_id, self.sales_team_1)
# keep only one membership -> default team
self.sales_team_1_m1.unlink()
self.assertEqual(self.user_sales_leads.crm_team_ids, self.new_team)
self.assertEqual(self.user_sales_leads.sale_team_id, self.new_team)
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" name="sales_team assets" inherit_id="web.assets_backend">
<template id="assets_backend" name="SalesTeam: backend assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" type="text/scss" href="/sales_team/static/src/scss/sales_team_dashboard.scss"/>
<link rel="stylesheet" type="text/scss" href="/sales_team/static/src/scss/crm_team_views.scss"/>
</xpath>
</template>
</odoo>
@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="crm_team_member_view_search" model="ir.ui.view">
<field name="name">crm.team.member.view.search</field>
<field name="model">crm.team.member</field>
<field name="arch" type="xml">
<search string="Sales Person">
<field name="name"/>
<field name="crm_team_id"/>
<separator/>
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Sales Team" name="groupby_crm_team_id" context="{'group_by': 'crm_team_id'}"/>
</group>
</search>
</field>
</record>
<record id="crm_team_member_view_tree" model="ir.ui.view">
<field name="name">crm.team.member.view.tree</field>
<field name="model">crm.team.member</field>
<field name="arch" type="xml">
<tree string="Sales Men" sample="1">
<field name="crm_team_id"/>
<field name="user_id"/>
</tree>
</field>
</record>
<record id="crm_team_member_view_tree_from_team" model="ir.ui.view">
<field name="name">crm.team.member.view.tree.from.team</field>
<field name="model">crm.team.member</field>
<field name="inherit_id" ref="sales_team.crm_team_member_view_tree"/>
<field name="mode">primary</field>
<field name="priority">32</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='crm_team_id']" position="replace">
</xpath>
</field>
</record>
<record id="crm_team_member_view_kanban" model="ir.ui.view">
<field name="name">crm.team.member.view.kanban</field>
<field name="model">crm.team.member</field>
<field name="arch" type="xml">
<kanban quick_create="false"
group_create="0"
records_draggable="0"
sample="1"
default_group_by="crm_team_id"
class="o_crm_team_member_kanban">
<field name="user_id"/>
<field name="active"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_card oe_kanban_global_click">
<div class="ribbon ribbon-top-right" attrs="{'invisible': [('active', '=', True)]}">
<span class="bg-error">Archived</span>
</div>
<div class="o_kanban_card_content d-flex">
<div>
<img t-att-src="kanban_image('res.users', 'image_128', record.user_id.raw_value)" class="o_kanban_image o_image_64_cover" alt="Avatar"/>
</div>
<div class="oe_kanban_details d-flex flex-column ml-3">
<strong class="o_kanban_record_title oe_partner_heading"><field name="user_id"/></strong>
<a type="open" class="nav-link p-0"><field name="crm_team_id"/></a>
<div class="d-flex align-items-baseline text-break">
<i class="fa fa-envelope mr-1" role="img" aria-label="Email" title="Email"/><field name="email"/>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="crm_team_member_view_kanban_from_team" model="ir.ui.view">
<field name="name">crm.team.member.view.kanban.from.team</field>
<field name="model">crm.team.member</field>
<field name="inherit_id" ref="sales_team.crm_team_member_view_kanban"/>
<field name="mode">primary</field>
<field name="priority">32</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='crm_team_id']" position="replace">
</xpath>
</field>
</record>
<record id="crm_team_member_view_form" model="ir.ui.view">
<field name="name">crm.team.member.view.form</field>
<field name="model">crm.team.member</field>
<field name="arch" type="xml">
<form string="Sales Men">
<field name="active" invisible="1"/>
<field name="is_membership_multi" invisible="1"/>
<field name="member_warning" invisible="1"/>
<field name="user_in_teams_ids" invisible="1"/>
<field name="user_company_ids" invisible="1"/>
<sheet>
<div class="alert alert-info text-center" role="alert"
attrs="{'invisible': [('member_warning', '=', False)]}">
<field name="member_warning"/>
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<field name="image_1920" widget='image' class="oe_avatar"
attrs="{'invisible': [('user_id', '=', False)]}"
options='{"preview_image": "image_128"}'/>
<div class="oe_title">
<label for="user_id" class="oe_edit_only"/>
<h1 class="d-flex">
<field name="user_id" class="flex-grow-1"
options="{'no_quick_create': True}"/>
</h1>
</div>
<group name="member_partner_info">
<group name="group_owner">
<field name="crm_team_id" kanban_view_ref="%(sales_team.crm_team_view_kanban)s"/>
</group>
<group name="group_user">
<field name="company_id" attrs="{'invisible': [('user_id', '=', False)]}"
groups="base.group_multi_company"/>
<field name="email" attrs="{'invisible': [('user_id', '=', False)]}"/>
<field name="mobile" attrs="{'invisible': [('user_id', '=', False)]}"/>
<field name="phone" attrs="{'invisible': [('user_id', '=', False)]}"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="crm_team_member_view_form_from_team" model="ir.ui.view">
<field name="name">crm.team.member.view.form.from.team</field>
<field name="model">crm.team.member</field>
<field name="inherit_id" ref="sales_team.crm_team_member_view_form"/>
<field name="mode">primary</field>
<field name="priority">32</field>
<field name="arch" type="xml">
<xpath expr="//group[@name='group_owner']" position="replace"/>
</field>
</record>
<record id="crm_team_member_action" model="ir.actions.act_window">
<field name="name">Team Members</field>
<field name="res_model">crm.team.member</field>
<field name="view_mode">kanban,tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new salesman
</p><p>
Link salespersons to sales teams.
</p>
</field>
</record>
</data></odoo>
+49 -24
View File
@@ -1,16 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="crm_team_salesteams_search" model="ir.ui.view">
<field name="name">Sales Teams - Search</field>
<record id="crm_team_view_search" model="ir.ui.view">
<field name="name">crm.team.view.search</field>
<field name="model">crm.team</field>
<field name="arch" type="xml">
<search string="Salesteams Search">
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<field name="name"/>
<field name="user_id"/>
<field name="member_ids"/>
<group expand="0" string="Group By...">
<filter string="Team Leader" name="team_leader" domain="[]" context="{'group_by':'user_id'}"/>
<filter string="Team Leader" name="team_leader" domain="[]" context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
@@ -21,39 +22,56 @@
<field name="model">crm.team</field>
<field name="arch" type="xml">
<form string="Sales Team">
<div class="alert alert-info text-center" role="alert"
attrs="{'invisible': ['|', ('is_membership_multi', '=', True), ('member_warning', '=', False)]}">
<field name="member_warning"/>
</div>
<sheet>
<div class="oe_button_box" name="button_box"/>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title">
<label for="name" class="oe_edit_only" string="Sales Team"/>
<h1>
<field class="o_text_overflow" name="name" placeholder="Sales Team name..."/>
<field name="name" placeholder="Customer Success"
class="o_text_overflow"/>
</h1>
<div name="options_active" />
<div name="options_active"/>
</div>
<group>
<group name="left">
<group name="left" string="Team Details">
<field name="active" invisible="1"/>
<field name="sequence" invisible="1"/>
<field name="is_membership_multi" invisible="1"/>
<field name="user_id" domain="[('share', '=', False)]"/>
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
<field name="currency_id" invisible="1"/>
<field name="member_company_ids" invisible="1"/>
</group>
<group name="right">
</group>
</group>
<notebook>
<page name="members" string="Team Members" >
<field name="member_ids" widget="many2many" options="{'not_delete': True}">
<kanban quick_create="false" create="true" delete="true">
<page string="Members" name="members_users">
<field name="member_ids" mode="kanban"
class="w-100">
<kanban>
<field name="id"/>
<field name="name"/>
<field name="email"/>
<field name="image_128"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click" style="max-width: 200px">
<div class="o_kanban_record_top">
<img t-att-src="kanban_image('res.users', 'image_128', record.id.raw_value)" class="oe_avatar oe_kanban_avatar_smallbox o_image_40_cover mb0" alt="Avatar"/>
<div class="o_kanban_record_headings ml8">
<strong class="o_kanban_record_title"><field name="name"/></strong>
<div class="oe_kanban_card oe_kanban_global_click">
<div class="o_kanban_card_content d-flex">
<div>
<img t-att-src="kanban_image('res.users', 'image_128', record.id.raw_value)"
class="o_kanban_image o_image_64_cover" alt="Avatar"/>
</div>
<div class="oe_kanban_details d-flex flex-column ml-3">
<strong class="o_kanban_record_title oe_partner_heading"><field name="name"/></strong>
<div class="d-flex align-items-baseline text-break">
<i class="fa fa-envelope mr-1" role="img" aria-label="Email" title="Email"/><field name="email"/>
</div>
</div>
</div>
</div>
@@ -61,6 +79,15 @@
</templates>
</kanban>
</field>
<field name="crm_team_member_ids" mode="kanban"
class="w-100"
attrs="{'invisible': ['|', ('is_membership_multi', '=', True), ('is_membership_multi', '=', False)]}"
context="{
'kanban_view_ref': 'sales_team.crm_team_member_view_kanban_from_team',
'form_view_ref': 'sales_team.crm_team_member_view_form_from_team',
'tree_view_ref': 'sales_team.crm_team_member_view_tree_from_team',
'default_crm_team_id': active_id,
}"/>
</page>
</notebook>
</sheet>
@@ -89,7 +116,7 @@
</record>
<record id="crm_team_view_kanban" model="ir.ui.view">
<field name="name">crm.team.kanban</field>
<field name="name">crm.team.view.kanban</field>
<field name="model">crm.team</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile" sample="1">
@@ -112,11 +139,12 @@
</record>
<!-- Case Teams Salesteams dashboard view -->
<record id="crm_team_salesteams_view_kanban" model="ir.ui.view" >
<field name="name">crm.team.dashboard</field>
<record id="crm_team_view_kanban_dashboard" model="ir.ui.view" >
<field name="name">crm.team.view.kanban.dashboard</field>
<field name="model">crm.team</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<kanban class="oe_background_grey o_kanban_dashboard o_salesteam_kanban" create="0" sample="1">
<kanban class="oe_background_grey o_kanban_dashboard o_crm_team_kanban" create="0" sample="1">
<field name="name"/>
<field name="user_id"/>
<field name="member_ids"/>
@@ -193,11 +221,10 @@
</record>
<!-- Sales Teams Actions -->
<record id="crm_team_salesteams_act" model="ir.actions.act_window">
<record id="crm_team_action_sales" model="ir.actions.act_window">
<field name="name">Sales Teams</field>
<field name="res_model">crm.team</field>
<field name="view_mode">kanban,form</field>
<field name="view_id" ref="crm_team_salesteams_search"/>
<field name="context">{'in_sales_app': True}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -209,11 +236,10 @@
</field>
</record>
<record id="crm_team_salesteams_pipelines_act" model="ir.actions.act_window">
<record id="crm_team_action_pipeline" model="ir.actions.act_window">
<field name="name">Teams</field>
<field name="res_model">crm.team</field>
<field name="view_mode">kanban,form</field>
<field name="view_id" ref="crm_team_salesteams_search"/>
<field name="context">{}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -225,12 +251,11 @@
</field>
</record>
<record id="sales_team_config_action" model="ir.actions.act_window">
<record id="crm_team_action_config" model="ir.actions.act_window">
<field name="name">Sales Teams</field>
<field name="res_model">crm.team</field>
<field name="view_mode">tree,form</field>
<field name="context">{}</field>
<field name="view_id" ref="crm_team_salesteams_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Define a new sales team
+4 -1
View File
@@ -1,10 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'eCommerce',
'category': 'Website/Website',
'sequence': 50,
'summary': 'Sell your products online',
'website': 'https://www.odoo.com/page/e-commerce',
'version': '1.0',
'version': '1.1',
'description': "",
'depends': ['website', 'sale', 'website_payment', 'website_mail', 'website_form', 'portal_rating', 'digest'],
'data': [
+3 -3
View File
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="crm_team_salesteams_view_kanban_inherit_website_sale" model="ir.ui.view">
<field name="name">crm.team.kanban</field>
<record id="crm_team_view_kanban_dashboard" model="ir.ui.view">
<field name="name">crm.team.view.kanban.dashboard.inherit.website.sale</field>
<field name="model">crm.team</field>
<field name="inherit_id" ref="sales_team.crm_team_salesteams_view_kanban"/>
<field name="inherit_id" ref="sales_team.crm_team_view_kanban_dashboard"/>
<field name="arch" type="xml">
<data>
<xpath expr="//t[@name='third_options']" position="after">