[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:
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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': ['|', '&', ('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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,5 @@
|
||||
|
||||
from . import common
|
||||
from . import test_sales_team
|
||||
from . import test_sales_team_internals
|
||||
from . import test_sales_team_membership
|
||||
|
||||
@@ -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)]
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user