diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index d8fce1f43c8..c591a62a77e 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -45,8 +45,7 @@ CRM_LEAD_FIELDS_TO_MERGE = [ 'date_action_last', 'email_from', 'email_cc', - 'website', - 'partner_name'] + 'website'] class Lead(models.Model): @@ -61,16 +60,8 @@ class Lead(models.Model): 'phone.validation.mixin'] _primary_email = 'email_from' - def _default_team_id(self, user_id): - domain = [('use_leads', '=', True)] if self._context.get('default_type') == "lead" or self.type == 'lead' else [('use_opportunities', '=', True)] - return self.env['crm.team']._get_default_team_id(user_id=user_id, domain=domain) - - def _default_stage_id(self): - team = self._default_team_id(user_id=self.env.uid) - return self._stage_find(team_id=team.id, domain=[('fold', '=', False)]).id - # Description - name = fields.Char('Opportunity', required=True, index=True) + name = fields.Char('Opportunity', index=True, required=True) user_id = fields.Many2one('res.users', string='Salesperson', index=True, tracking=True, default=lambda self: self.env.user) user_email = fields.Char('User Email', related='user_id.email', readonly=True) user_login = fields.Char('User Login', related='user_id.login', readonly=True) @@ -81,22 +72,23 @@ class Lead(models.Model): type = fields.Selection([ ('lead', 'Lead'), ('opportunity', 'Opportunity')], index=True, required=True, tracking=15, - default=lambda self: 'lead' if self.env['res.users'].has_group('crm.group_use_lead') else 'opportunity', - help="Type is used to separate Leads and Opportunities") + default=lambda self: 'lead' if self.env['res.users'].has_group('crm.group_use_lead') else 'opportunity') priority = fields.Selection( 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, - default=lambda self: self._default_team_id(self.env.uid)) + compute='_compute_team_id', copy=True, readonly=False, store=True) stage_id = fields.Many2one( - 'crm.stage', string='Stage', ondelete='restrict', - copy=False, group_expand='_read_group_stage_ids', index=True, tracking=True, - domain="['|', ('team_id', '=', False), ('team_id', '=', team_id)]", - default=lambda self: self._default_stage_id()) + 'crm.stage', string='Stage', index=True, tracking=True, + compute='_compute_stage_id', readonly=False, store=True, + copy=False, group_expand='_read_group_stage_ids', ondelete='restrict', + domain="['|', ('team_id', '=', False), ('team_id', '=', team_id)]") kanban_state = fields.Selection([ - ('grey', 'No next activity planned'), ('red', 'Next activity late'), ('green', 'Next activity is planned')], - string='Kanban State', compute='_compute_kanban_state') + ('grey', 'No next activity planned'), + ('red', 'Next activity late'), + ('green', 'Next activity is planned')], string='Kanban State', + compute='_compute_kanban_state') tag_ids = fields.Many2many( 'crm.tag', 'crm_tag_rel', 'lead_id', 'tag_id', string='Tags', help="Classify and analyze your lead/opportunity categories like: Training, Service") @@ -108,26 +100,37 @@ class Lead(models.Model): # Dates date_closed = fields.Datetime('Closed Date', readonly=True, copy=False) date_action_last = fields.Datetime('Last Action', readonly=True) - date_open = fields.Datetime('Assignment Date', readonly=True, default=fields.Datetime.now) + date_open = fields.Datetime( + 'Assignment Date', compute='_compute_date_open', readonly=True, store=True) day_open = fields.Float('Days to Assign', compute='_compute_day_open', store=True) day_close = fields.Float('Days to Close', compute='_compute_day_close', store=True) - date_last_stage_update = fields.Datetime('Last Stage Update', index=True, default=fields.Datetime.now) + date_last_stage_update = fields.Datetime( + 'Last Stage Update', compute='_compute_date_last_stage_update', index=True, readonly=True, store=True) date_conversion = fields.Datetime('Conversion Date', readonly=True) 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', tracking=10, index=True, + 'res.partner', string='Customer', 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_address_email = fields.Char('Partner Contact Email', related='partner_id.email', readonly=True) partner_is_blacklisted = fields.Boolean('Partner is blacklisted', related='partner_id.is_blacklisted', readonly=True) - contact_name = fields.Char('Contact Name', tracking=30) - partner_name = fields.Char("Company Name", tracking=20, index=True, help='The name of the future partner company that will be created while converting the lead into opportunity') - function = fields.Char('Job Position') - title = fields.Many2one('res.partner.title', string='Title') - email_from = fields.Char('Email', index=True, tracking=40) - phone = fields.Char('Phone', tracking=50) - mobile = fields.Char('Mobile') + contact_name = fields.Char( + 'Contact Name', tracking=30, + compute='_compute_partner_id_values', readonly=False, store=True) + partner_name = fields.Char( + 'Company Name', tracking=20, index=True, + compute='_compute_partner_id_values', readonly=False, store=True, + help='The name of the future partner company that will be created while converting the lead into opportunity') + function = fields.Char('Job Position', compute='_compute_partner_id_values', readonly=False, store=True) + title = fields.Many2one('res.partner.title', string='Title',compute='_compute_partner_id_values', readonly=False, store=True) + email_from = fields.Char( + 'Email', tracking=40, index=True, + compute='_compute_partner_id_values', readonly=False, store=True) + phone = fields.Char( + 'Phone', tracking=50, + compute='_compute_partner_id_values', readonly=False, store=True) + mobile = fields.Char('Mobile', compute='_compute_partner_id_values', readonly=False, store=True) phone_mobile_search = fields.Char('Phone/Mobile', store=False, search='_search_phone_mobile_search') phone_state = fields.Selection([ ('correct', 'Correct'), @@ -135,18 +138,25 @@ class Lead(models.Model): email_state = fields.Selection([ ('correct', 'Correct'), ('incorrect', 'Incorrect')], string='Email Quality', compute="_compute_email_state", store=True) - website = fields.Char('Website', index=True, help="Website of the contact") + website = fields.Char('Website', index=True, help="Website of the contact", compute="_compute_partner_id_values", store=True, readonly=False) lang_id = fields.Many2one('res.lang', string='Language') # Address fields - street = fields.Char('Street') - street2 = fields.Char('Street2') - zip = fields.Char('Zip', change_default=True) - city = fields.Char('City') - state_id = fields.Many2one("res.country.state", string='State', domain="[('country_id', '=?', country_id)]") - country_id = fields.Many2one('res.country', string='Country') + street = fields.Char('Street', compute='_compute_partner_id_values', readonly=False, store=True) + street2 = fields.Char('Street2', compute='_compute_partner_id_values', readonly=False, store=True) + zip = fields.Char('Zip', change_default=True, compute='_compute_partner_id_values', readonly=False, store=True) + city = fields.Char('City', compute='_compute_partner_id_values', readonly=False, store=True) + state_id = fields.Many2one( + "res.country.state", string='State', + compute='_compute_partner_id_values', readonly=False, store=True, + domain="[('country_id', '=?', country_id)]") + country_id = fields.Many2one( + 'res.country', string='Country', + compute='_compute_partner_id_values', readonly=False, store=True) # Probability (Opportunity only) - probability = fields.Float('Probability', group_operator="avg", copy=False) - automated_probability = fields.Float('Automated Probability', readonly=True) + probability = fields.Float( + 'Probability', group_operator="avg", copy=False, + compute='_compute_probabilities', readonly=False, store=True) + automated_probability = fields.Float('Automated Probability', compute='_compute_probabilities', readonly=True, store=True) is_automated_probability = fields.Boolean('Is automated probability?', compute="_compute_is_automated_probability") # External records meeting_count = fields.Integer('# Meetings', compute='_compute_meeting_count') @@ -170,12 +180,38 @@ class Lead(models.Model): kanban_state = 'red' lead.kanban_state = kanban_state - @api.depends('planned_revenue', 'probability') - def _compute_expected_revenue(self): + @api.depends('user_id', 'type') + def _compute_team_id(self): + """ When changing the user, also set a team_id or restrict team id + to the ones user_id is member of. """ for lead in self: - lead.expected_revenue = round((lead.planned_revenue or 0.0) * (lead.probability or 0) / 100.0, 2) + # setting user as void should not trigger a new team computation + 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: + 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('date_open') + @api.depends('team_id', 'type') + def _compute_stage_id(self): + for lead in self: + if not lead.stage_id: + lead.stage_id = lead._stage_find(domain=[('fold', '=', False)]).id + + @api.depends('user_id') + def _compute_date_open(self): + for lead in self: + lead.date_open = fields.Datetime.now() if lead.user_id else False + + @api.depends('stage_id') + def _compute_date_last_stage_update(self): + for lead in self: + lead.date_last_stage_update = fields.Datetime.now() + + @api.depends('create_date', 'date_open') def _compute_day_open(self): """ Compute difference between create date and open date """ leads = self.filtered(lambda l: l.date_open and l.create_date) @@ -186,7 +222,7 @@ class Lead(models.Model): date_open = fields.Datetime.from_string(lead.date_open) lead.day_open = abs((date_open - date_create).days) - @api.depends('date_closed') + @api.depends('create_date', 'date_closed') def _compute_day_close(self): """ Compute difference between current date and log date """ leads = self.filtered(lambda l: l.date_closed and l.create_date) @@ -197,13 +233,11 @@ class Lead(models.Model): date_close = fields.Datetime.from_string(lead.date_closed) lead.day_close = abs((date_close - date_create).days) - @api.depends('probability', 'automated_probability') - def _compute_is_automated_probability(self): - """ If probability and automated_probability are equal, the probability is considered as automatic. - when the probability is in automatic mode, the field probability will be aligned - when automated_probability is modified. """ + @api.depends('partner_id') + def _compute_partner_id_values(self): + """ compute the new values when partner_id has changed """ for lead in self: - lead.is_automated_probability = tools.float_compare(lead.probability, lead.automated_probability, 2) == 0 + lead.update(lead._preare_values_from_partner(lead.partner_id)) @api.depends('phone', 'country_id.code') def _compute_phone_state(self): @@ -230,6 +264,33 @@ class Lead(models.Model): break lead.email_state = email_state + @api.depends('probability', 'automated_probability') + def _compute_is_automated_probability(self): + """ If probability and automated_probability are equal probability computation + is considered as automatic, aka probability is sync with automated_probability """ + for lead in self: + # creation mode: consider it as being not automated + if not lead.id and not lead._origin.id: + lead.is_automated_probability = False + else: + lead.is_automated_probability = tools.float_compare(lead.probability, lead.automated_probability, 2) == 0 + + @api.depends(lambda self: ['tag_ids', 'stage_id', 'team_id'] + self._pls_get_safe_fields()) + def _compute_probabilities(self): + for lead in self: + was_automated = False + lead_probabilities = lead._pls_get_naive_bayes_probabilities() + if lead.id in lead_probabilities: + was_automated = lead.active and lead.is_automated_probability + lead.automated_probability = lead_probabilities[lead.id] + if was_automated: + lead.probability = lead.automated_probability + + @api.depends('planned_revenue', 'probability') + def _compute_expected_revenue(self): + for lead in self: + lead.expected_revenue = round((lead.planned_revenue or 0.0) * (lead.probability or 0) / 100.0, 2) + def _compute_meeting_count(self): meeting_data = self.env['calendar.event'].read_group([('opportunity_id', 'in', self.ids)], ['opportunity_id'], ['opportunity_id']) mapped_data = {m['opportunity_id'][0]: m['opportunity_id_count'] for m in meeting_data} @@ -261,121 +322,6 @@ class Lead(models.Model): return [(0, '=', 1)] return [('id', 'in', [r[0] for r in res])] - def _onchange_partner_id_values(self, partner_id): - """ returns the new values when partner_id has changed """ - if partner_id: - partner = self.env['res.partner'].browse(partner_id) - - partner_name = partner.parent_id.name - if not partner_name and partner.is_company: - partner_name = partner.name - - return { - 'partner_name': partner_name, - 'contact_name': partner.name if not partner.is_company else False, - 'title': partner.title.id, - 'street': partner.street, - 'street2': partner.street2, - 'city': partner.city, - 'state_id': partner.state_id.id, - 'country_id': partner.country_id.id, - 'email_from': partner.email, - 'phone': partner.phone, - 'mobile': partner.mobile, - 'zip': partner.zip, - 'function': partner.function, - 'website': partner.website, - } - return {} - - @api.onchange('partner_id') - def _onchange_partner_id(self): - values = self._onchange_partner_id_values(self.partner_id.id if self.partner_id else False) - self.update(values) - - @api.model - def _onchange_user_values(self, user_id): - """ returns new values when user_id has changed """ - if not user_id: - return {} - if user_id and self._context.get('team_id'): - team = self.env['crm.team'].browse(self._context['team_id']) - if user_id in team.member_ids.ids or user_id == team.user_id.id: - return {} - team_id = self._default_team_id(user_id) - return {'team_id': team_id} - - @api.onchange('user_id') - def _onchange_user_id(self): - """ When changing the user, also set a team_id or restrict team id to the ones user_id is member of. """ - if self.user_id.sale_team_id: - values = self._onchange_user_values(self.user_id.id) - self.update(values) - - def _onchange_compute_probability(self, optional_field_name=None): - """Recompute probability on onchange methods of : - 'stage_id', 'team_id', 'tag_ids' - 'country_id', 'state_id', 'phone_state', 'email_state', 'source_id' """ - if optional_field_name and optional_field_name not in self._pls_get_safe_fields(): - return - lead_probabilities = self._pls_get_naive_bayes_probabilities() - if self.id in lead_probabilities: - self.automated_probability = lead_probabilities[self.id] - if self._origin.is_automated_probability: - self.probability = self.automated_probability - - @api.onchange('stage_id') - def _onchange_stage_id(self): - self._onchange_compute_probability() - - @api.onchange('source_id') - def _onchange_source_id(self): - self._onchange_compute_probability(optional_field_name='source_id') - - @api.onchange('automated_probability') - def _onchange_automated_probability(self): - """ If was in auto mode before the change, align both fields so that it will stay in auto mode. """ - if self._origin.is_automated_probability: - self.probability = self.automated_probability - - @api.onchange('team_id') - def _onchange_team_id(self): - self._onchange_compute_probability() - - @api.onchange('tag_ids') - def _onchange_tag_ids(self): - self._onchange_compute_probability() - - @api.onchange('phone_state') - def _onchange_phone_state(self): - self._onchange_compute_probability(optional_field_name='phone_state') - - @api.onchange('email_state') - def _onchange_email_state(self): - self._onchange_compute_probability(optional_field_name='email_state') - - @api.constrains('user_id') - def _valid_team(self): - for lead in self: - if lead.user_id: - values = lead.with_context(team_id=lead.team_id.id)._onchange_user_values(lead.user_id.id) - if values: - lead.update(values) - - @api.onchange('state_id') - def _onchange_state(self): - self._onchange_compute_probability(optional_field_name='state_id') - if self.state_id: - self.country_id = self.state_id.country_id.id - - @api.onchange('country_id') - def _onchange_country_id(self): - self._onchange_compute_probability(optional_field_name='country_id') - - @api.onchange('lang_id') - def _onchange_lang_id(self): - self._onchange_compute_probability(optional_field_name='lang_id') - @api.onchange('phone', 'country_id', 'company_id') def _onchange_phone_validation(self): if self.phone: @@ -386,6 +332,30 @@ class Lead(models.Model): if self.mobile: self.mobile = self.phone_format(self.mobile) + def _preare_values_from_partner(self, partner): + """ Get a dictionary with values coming from customer information to + copy on a lead. Email_from and phone fields get the current lead + values to avoid being reset if customer has no value for them. """ + partner_name = partner.parent_id.name + if not partner_name and partner.is_company: + partner_name = partner.name + return { + 'partner_name': partner_name, + 'contact_name': partner.name if not partner.is_company else False, + 'title': partner.title.id, + 'street': partner.street, + 'street2': partner.street2, + 'city': partner.city, + 'state_id': partner.state_id.id, + 'country_id': partner.country_id.id, + 'email_from': partner.email or self.email_from, + 'phone': partner.phone or self.phone, + 'mobile': partner.mobile, + 'zip': partner.zip, + 'function': partner.function, + 'website': partner.website, + } + # ------------------------------------------------------------ # ORM # ------------------------------------------------------------ @@ -400,44 +370,23 @@ class Lead(models.Model): @api.model def create(self, vals): - # set up context used to find the lead's Sales Team which is needed - # to correctly set the default stage_id - context = dict(self._context or {}) - if vals.get('type') and not self._context.get('default_type'): - context['default_type'] = vals.get('type') - if vals.get('team_id') and not self._context.get('default_team_id'): - context['default_team_id'] = vals.get('team_id') - - if vals.get('user_id') and 'date_open' not in vals: - vals['date_open'] = fields.Datetime.now() - - partner_id = vals.get('partner_id') or context.get('default_partner_id') - onchange_values = self._onchange_partner_id_values(partner_id) - onchange_values.update(vals) # we don't want to overwrite any existing key - vals = onchange_values - - result = super(Lead, self.with_context(context)).create(vals) + lead = super(Lead, self).create(vals) # Compute new probability for each lead separately - result._update_probability() - return result + lead._update_probability() + return lead def write(self, vals): # stage change: if 'stage_id' in vals: - vals['date_last_stage_update'] = fields.Datetime.now() stage_id = self.env['crm.stage'].browse(vals['stage_id']) if stage_id.is_won: vals.update({'probability': 100}) - # Only write the 'date_open' if no salesperson was assigned. - if vals.get('user_id') and 'date_open' not in vals and not self.mapped('user_id'): - vals['date_open'] = fields.Datetime.now() + # stage change with new stage: update probability and date_closed if vals.get('probability', 0) >= 100 or not vals.get('active', True): vals['date_closed'] = fields.Datetime.now() elif 'probability' in vals: vals['date_closed'] = False - if vals.get('user_id') and 'date_open' not in vals: - vals['date_open'] = fields.Datetime.now() write_result = super(Lead, self).write(vals) # Compute new automated_probability (and, eventually, probability) for each lead separately @@ -452,7 +401,7 @@ class Lead(models.Model): if lead.id in lead_probabilities: lead_proba = lead_probabilities[lead.id] proba_vals = {'automated_probability': lead_proba} - if tools.float_compare(lead.probability, lead.automated_probability, 2) == 0: + if lead.active and lead.is_automated_probability: proba_vals['probability'] = lead_proba super(Lead, lead).write(proba_vals) return @@ -870,51 +819,18 @@ class Lead(models.Model): return self.sorted(key=opps_key, reverse=reverse) - def get_duplicated_leads(self, partner_id, include_lost=False): - """ Search for opportunities that have the same partner and that arent done or cancelled - :param partner_id : partner to search - """ - self.ensure_one() - email = self.partner_id.email or self.email_from - return self._get_duplicated_leads_by_emails(partner_id, email, include_lost=include_lost) - - @api.model - def _get_duplicated_leads_by_emails(self, partner_id, email, include_lost=False): - """ Search for opportunities that have the same partner and that arent done or cancelled """ - if not email: - return self.env['crm.lead'] - partner_match_domain = [] - for email in set(email_split(email) + [email]): - partner_match_domain.append(('email_from', '=ilike', email)) - if partner_id: - partner_match_domain.append(('partner_id', '=', partner_id)) - partner_match_domain = ['|'] * (len(partner_match_domain) - 1) + partner_match_domain - if not partner_match_domain: - return self.env['crm.lead'] - domain = partner_match_domain - if not include_lost: - domain += ['&', ('active', '=', True), ('probability', '<', 100)] - else: - domain += ['|', '&', ('type', '=', 'lead'), ('active', '=', True), ('type', '=', 'opportunity')] - return self.search(domain) - def _convert_opportunity_data(self, customer, team_id=False): """ Extract the data from a lead to create the opportunity :param customer : res.partner record :param team_id : identifier of the Sales Team to determine the stage """ new_team_id = team_id if team_id else self.team_id.id - upd_values = {} - if customer: - upd_values.update(self._onchange_partner_id_values(customer and customer.id)) - upd_values['email_from'] = upd_values['email_from'] if upd_values.get('email_from') else self.email_from - upd_values['phone'] = upd_values['phone'] if upd_values.get('phone') else self.phone - upd_values.update({ + upd_values = { 'partner_id': customer.id if customer else False, 'type': 'opportunity', 'date_open': fields.Datetime.now(), 'date_conversion': fields.Datetime.now(), - }) + } if not self.stage_id: stage = self._stage_find(team_id=new_team_id) upd_values['stage_id'] = stage.id @@ -931,11 +847,64 @@ class Lead(models.Model): lead.write(vals) if user_ids or team_id: - self.allocate_salesman(user_ids, team_id) + self.handle_salesmen_assignment(user_ids, team_id) return True - def _create_lead_partner_data(self, partner_name, is_company=False, parent_id=False): + def _get_lead_duplicates(self, partner=None, email=None, include_lost=False): + """ Search for leads that seem duplicated based on partner / email. + + :param partner : optional customer when searching duplicated + :param email: email (possibly formatted) to search + :param boolean include_lost: if True, search includes archived opportunities + (still only active leads are considered). If False, search for active + and not won leads and opportunities; + """ + if not email and not partner: + return self.env['crm.lead'] + + domain = [] + for normalized_email in [tools.email_normalize(email) for email in tools.email_split(email)]: + domain.append(('email_normalized', '=', normalized_email)) + if partner: + domain.append(('partner_id', '=', partner.id)) + domain = ['|'] * (len(domain) - 1) + domain + + if not domain: + return self.env['crm.lead'] + + if include_lost: + domain += ['|', ('type', '=', 'opportunity'), ('active', '=', True)] + else: + domain += ['&', ('active', '=', True), '|', ('probability', '=', False), ('probability', '<', 100)] + + return self.with_context(active_test=False).search(domain) + + def _create_customer(self): + """ Create a partner from lead data and link it to the lead. + + :return: newly-created partner browse record + """ + Partner = self.env['res.partner'] + contact_name = self.contact_name + if not contact_name: + contact_name = Partner._parse_partner_name(self.email_from)[0] if self.email_from else False + + if self.partner_name: + partner_company = Partner.create(self._prepare_customer_values(self.partner_name, is_company=True)) + elif self.partner_id: + partner_company = self.partner_id + else: + partner_company = None + + if contact_name: + return Partner.create(self._prepare_customer_values(contact_name, is_company=False, parent_id=partner_company.id if partner_company else False)) + + if partner_company: + return partner_company + return Partner.create(self._prepare_customer_values(self.name, is_company=False)) + + def _prepare_customer_values(self, partner_name, is_company=False, parent_id=False): """ Extract data from lead to create a partner. :param name : furtur name of the partner @@ -967,107 +936,59 @@ class Lead(models.Model): 'type': 'contact' } - def _create_lead_partner(self): - """ Create a partner from lead data - :returns res.partner record - """ - Partner = self.env['res.partner'] - contact_name = self.contact_name - if not contact_name: - contact_name = Partner._parse_partner_name(self.email_from)[0] if self.email_from else False - - if self.partner_name: - partner_company = Partner.create(self._create_lead_partner_data(self.partner_name, is_company=True)) - elif self.partner_id: - partner_company = self.partner_id - else: - partner_company = None - - if contact_name: - return Partner.create(self._create_lead_partner_data(contact_name, is_company=False, parent_id=partner_company.id if partner_company else False)) - - if partner_company: - return partner_company - return Partner.create(self._create_lead_partner_data(self.name, is_company=False)) - def _find_matching_partner(self): - """ Try to find a matching partner with available information on the Lead. - Like the customer's name, email, phone number, etc. - :return int partner_id if any, False otherwise + """ Try to find a matching partner with available information on the + lead, using notably customer's name, email, phone, ... + + :return: partner browse record """ self.ensure_one() + partner = self.partner_id - # find the best matching partner - Partner = self.env['res.partner'] - if self.partner_id: # a partner is set already - return self.partner_id.id + if not partner and self.email_from: + partner = self.env['res.partner'].search([('email', '=', self.email_from)], limit=1) - if self.email_from: # search through the existing partners based on the lead's email - return Partner.search([('email', '=', self.email_from)], limit=1).id + if not partner: + # search through the existing partners based on the lead's partner or contact name + # to be aligned with _create_customer, search on lead's name as last possibility + for customer_potential_name in [self[field_name] for field_name in ['partner_name', 'contact_name', 'name'] if self[field_name]]: + partner = self.env['res.partner'].search([('name', 'ilike', '%' + customer_potential_name + '%')], limit=1) + if partner: + break - search_criteria = False - if self.partner_name: # search through the existing partners based on the lead's partner or contact name - search_criteria = self.partner_name - elif self.contact_name: - search_criteria = self.contact_name - elif self.name: # to be aligned with _create_lead_partner, search on lead's name as last possibility - search_criteria = self.name + return partner - if search_criteria: - return Partner.search([('name', 'ilike', '%' + search_criteria + '%')], limit=1).id + def handle_partner_assignment(self, force_partner_id=False, create_missing=True): + """ Update customer (partner_id) of leads. Purpose is to set the same + partner on most leads; either through a newly created partner either + through a given partner_id. - return False - - def handle_partner_assignation(self, action='create', partner_id=False): - """ Handle partner assignation during a lead conversion. - - If action is 'create', create new partner with contact and assign lead to - new partner_id. Otherwise assign lead to the specified partner_id - - TDE FIXME: docstring does not match code... code seems a bit random. - - :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing) - :param int partner_id: partner to assign if any - :return: dict(lead_id: partner_id) + :param int force_partner_id: if set, update all leads to that customer; + :param create_missing: for leads without customer, create a new one + based on lead information; """ - partner_ids = {} for lead in self: - if partner_id: - lead.partner_id = partner_id - if lead.partner_id: - partner_ids[lead.id] = lead.partner_id.id - continue - if action == 'create': - partner = lead._create_lead_partner() - partner_id = partner.id - partner.team_id = lead.team_id - partner_ids[lead.id] = partner_id - return partner_ids + if force_partner_id: + lead.partner_id = force_partner_id + if not lead.partner_id and create_missing: + partner = lead._create_customer() + lead.partner_id = partner.id - def allocate_salesman(self, user_ids=None, team_id=False): + def handle_salesmen_assignment(self, user_ids=None, team_id=False): """ Assign salesmen and salesteam to a batch of leads. If there are more - leads than salesmen, these salesmen will be assigned in round-robin. - E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6). They - will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4, - L5 - S1, L6 - S2. + leads than salesmen, these salesmen will be assigned in round-robin. E.g. + 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6) will assigned as + following: L1 - S1, L2 - S2, L3 - S3, L4 - S4, L5 - S1, L6 - S2. - :param list ids: leads/opportunities ids to process - :param list user_ids: salesmen to assign - :param int team_id: salesteam to assign - :return bool + :param list user_ids: salesmen to assign + :param int team_id: salesteam to assign """ - index = 0 - for lead in self: - value = {} - if team_id: - value['team_id'] = team_id + update_vals = {'team_id': team_id} if team_id else {} + for index, lead in enumerate(self): if user_ids: - value['user_id'] = user_ids[index] - # Cycle through user_ids - index = (index + 1) % len(user_ids) - if value: - lead.write(value) - return True + update_vals['user_id'] = user_ids[index % len(user_ids)] + if update_vals: + lead.write(update_vals) # ------------------------------------------------------------ # TOOLS @@ -1200,8 +1121,6 @@ class Lead(models.Model): 'email_from': msg_dict.get('from'), 'partner_id': msg_dict.get('author_id', False), } - if msg_dict.get('author_id'): - defaults.update(self._onchange_partner_id_values(msg_dict.get('author_id'))) if msg_dict.get('priority') in dict(crm_stage.AVAILABLE_PRIORITIES): defaults['priority'] = msg_dict.get('priority') defaults.update(custom_values) diff --git a/addons/crm/models/crm_team.py b/addons/crm/models/crm_team.py index f6a2b1c83b3..a02b7f34433 100644 --- a/addons/crm/models/crm_team.py +++ b/addons/crm/models/crm_team.py @@ -1,13 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from dateutil.relativedelta import relativedelta +import ast +import datetime from odoo import api, fields, models, _ from odoo.tools.safe_eval import safe_eval -from odoo.exceptions import ValidationError -import ast class Team(models.Model): _name = 'crm.team' @@ -17,30 +16,26 @@ class Team(models.Model): use_leads = fields.Boolean('Leads', help="Check this box to filter and qualify incoming requests as leads before converting them into opportunities and assigning them to a salesperson.") use_opportunities = fields.Boolean('Pipeline', default=True, help="Check this box to manage a presales process with opportunities.") alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=True, help="The email address associated with this channel. New emails received will automatically create new leads assigned to the channel.") - - unassigned_leads_count = fields.Integer( - compute='_compute_unassigned_leads_count', - string='Unassigned Leads') + # statistics about leads / opportunities / both + lead_unassigned_count = fields.Integer( + string='# Unassigned Leads', compute='_compute_lead_unassigned_count') + lead_all_assigned_month_count = fields.Integer( + string='# Leads/Opps assigned this month', compute='_compute_lead_all_assigned_month_count', + help="Number of leads and opportunities assigned this last month.") opportunities_count = fields.Integer( - compute='_compute_opportunities', - string='Number of open opportunities') - overdue_opportunities_count = fields.Integer( - compute='_compute_overdue_opportunities', - string='Number of overdue opportunities') + string='# Opportunities', compute='_compute_opportunities_data') opportunities_amount = fields.Integer( - compute='_compute_opportunities', - string='Opportunities Revenues') - overdue_opportunities_amount = fields.Integer( - compute='_compute_overdue_opportunities', - string='Overdue Opportunities Revenues') + string='Opportunities Revenues', compute='_compute_opportunities_data') + opportunities_overdue_count = fields.Integer( + string='# Overdue Opportunities', compute='_compute_opportunities_overdue_data') + opportunities_overdue_amount = fields.Integer( + string='Overdue Opportunities Revenues', compute='_compute_opportunities_overdue_data',) + # alias: improve fields coming from _inherits, use inherited to avoid replacing them + alias_user_id = fields.Many2one( + 'res.users', related='alias_id.alias_user_id', inherited=True, + domain=lambda self: [('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman_all_leads').id)]) - # Since we are in a _inherits case, this is not an override - # but a plain definition of a field - # So we need to reset the property related of that field - alias_user_id = fields.Many2one('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)]) - - def _compute_unassigned_leads_count(self): + def _compute_lead_unassigned_count(self): leads_data = self.env['crm.lead'].read_group([ ('team_id', 'in', self.ids), ('type', '=', 'lead'), @@ -48,9 +43,20 @@ class Team(models.Model): ], ['team_id'], ['team_id']) counts = {datum['team_id'][0]: datum['team_id_count'] for datum in leads_data} for team in self: - team.unassigned_leads_count = counts.get(team.id, 0) + team.lead_unassigned_count = counts.get(team.id, 0) - def _compute_opportunities(self): + 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) + + def _compute_opportunities_data(self): opportunity_data = self.env['crm.lead'].search([ ('team_id', 'in', self.ids), ('probability', '<', 100), @@ -67,7 +73,7 @@ class Team(models.Model): team.opportunities_count = counts.get(team.id, 0) team.opportunities_amount = amounts.get(team.id, 0) - def _compute_overdue_opportunities(self): + def _compute_opportunities_overdue_data(self): opportunity_data = self.env['crm.lead'].read_group([ ('team_id', 'in', self.ids), ('probability', '<', 100), @@ -77,14 +83,46 @@ class Team(models.Model): counts = {datum['team_id'][0]: datum['team_id_count'] for datum in opportunity_data} amounts = {datum['team_id'][0]: (datum['planned_revenue']) for datum in opportunity_data} for team in self: - team.overdue_opportunities_count = counts.get(team.id, 0) - team.overdue_opportunities_amount = amounts.get(team.id, 0) + team.opportunities_overdue_count = counts.get(team.id, 0) + team.opportunities_overdue_amount = amounts.get(team.id, 0) @api.onchange('use_leads', 'use_opportunities') def _onchange_use_leads_opportunities(self): if not self.use_leads and not self.use_opportunities: self.alias_name = False + # ------------------------------------------------------------ + # ORM + # ------------------------------------------------------------ + + @api.model + def create(self, vals): + alias_values = self._synchronize_alias(vals) + if alias_values: + vals.update(alias_values) + return super(Team, self).create(vals) + + def write(self, vals): + result = super(Team, self).write(vals) + if 'use_leads' in vals or 'alias_defaults' in vals: + for team in self: + team.alias_id.write(team.get_alias_values()) + if 'use_leads' in vals or 'use_opportunities' in vals: + alias_values = self._synchronize_alias(vals) + if alias_values: + self.write(alias_values) + return result + + def _synchronize_alias(self, values): + use_leads = self.use_leads if self else values.get('use_leads', False) + use_opportunities = self.use_opportunities if self else values.get('use_opportunities', True) + if not use_leads and not use_opportunities: + return {'alias_name': False} + return {} + # ------------------------------------------------------------ + # MESSAGING + # ------------------------------------------------------------ + def get_alias_model_name(self, vals): return 'crm.lead' @@ -96,12 +134,9 @@ class Team(models.Model): defaults['team_id'] = self.id return values - def write(self, vals): - result = super(Team, self).write(vals) - if 'use_leads' in vals or 'alias_defaults' in vals: - for team in self: - team.alias_id.write(team.get_alias_values()) - return result + # ------------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------------ #TODO JEM : refactor this stuff with xml action, proper customization, @api.model diff --git a/addons/crm/models/res_config_settings.py b/addons/crm/models/res_config_settings.py index 3861c1c411f..258caf44d56 100644 --- a/addons/crm/models/res_config_settings.py +++ b/addons/crm/models/res_config_settings.py @@ -2,14 +2,17 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models -from datetime import date class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' - crm_alias_prefix = fields.Char('Default Alias Name for Leads') - generate_lead_from_alias = fields.Boolean('Manual Assignment of Emails', config_parameter='crm.generate_lead_from_alias') + crm_alias_prefix = fields.Char( + 'Default Alias Name for Leads', + compute="_compute_crm_alias_prefix" , readonly=False, store=True) + generate_lead_from_alias = fields.Boolean( + 'Manual Assignment of Emails', config_parameter='crm.generate_lead_from_alias', + compute="_compute_generate_lead_from_alias", readonly=False, store=True) group_use_lead = fields.Boolean(string="Leads", implied_group='crm.group_use_lead') 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") @@ -71,15 +74,17 @@ class ResConfigSettings(models.TransientModel): if setting.predictive_lead_scoring_start_date: setting.predictive_lead_scoring_start_date_str = fields.Date.to_string(setting.predictive_lead_scoring_start_date) - @api.onchange('group_use_lead') - def _onchange_group_use_lead(self): + @api.depends('group_use_lead') + def _compute_generate_lead_from_alias(self): """ Reset alias / leads configuration if leads are not used """ if not self.group_use_lead: - self.generate_lead_from_alias = False + for setting in self: + setting.generate_lead_from_alias = False - @api.onchange('generate_lead_from_alias') - def _onchange_generate_lead_from_alias(self): - self.crm_alias_prefix = (self.crm_alias_prefix or 'info') if self.generate_lead_from_alias else False + @api.depends('generate_lead_from_alias') + def _compute_crm_alias_prefix(self): + for setting in self: + setting.crm_alias_prefix = (setting.crm_alias_prefix or 'info') if setting.generate_lead_from_alias else False @api.model def get_values(self): diff --git a/addons/crm/models/res_users.py b/addons/crm/models/res_users.py index 2c94f1c05ab..951287d5958 100644 --- a/addons/crm/models/res_users.py +++ b/addons/crm/models/res_users.py @@ -5,7 +5,6 @@ from odoo import fields, models class Users(models.Model): - _inherit = 'res.users' target_sales_won = fields.Integer('Won in Opportunities Target') diff --git a/addons/crm/tests/common.py b/addons/crm/tests/common.py index 2fbfcc0affa..c1171e4c44d 100644 --- a/addons/crm/tests/common.py +++ b/addons/crm/tests/common.py @@ -116,6 +116,10 @@ class TestCrmCommon(SavepointCase, MailCase): 'contact_name': 'Amy Wong', 'email_from': 'amy.wong@test.example.com', }) + # update lead_1: stage_id is not computed anymore by default for leads + cls.lead_1.write({ + 'stage_id': cls.stage_team1_1.id, + }) # create an history for new team cls.lead_team_1_won = cls.env['crm.lead'].create({ @@ -303,6 +307,7 @@ class TestLeadConvertMassCommon(TestLeadConvertCommon): 'stage_id': False, 'partner_id': cls.contact_1.id, }) + cls.lead_w_partner.write({'stage_id': False}) cls.lead_w_partner_company = cls.env['crm.lead'].create({ 'name': 'New1', 'type': 'lead', @@ -338,6 +343,4 @@ class TestLeadConvertMassCommon(TestLeadConvertCommon): 'stage_id': cls.stage_team1_2.id, 'active': False, }) - for l in (cls.lead_w_partner | cls.lead_w_partner_company | cls.lead_w_contact | cls.lead_w_email | cls.lead_w_email_lost): - l._onchange_user_id() (cls.lead_w_partner | cls.lead_w_partner_company | cls.lead_w_contact | cls.lead_w_email | cls.lead_w_email_lost).flush() diff --git a/addons/crm/tests/test_crm_lead.py b/addons/crm/tests/test_crm_lead.py index d8c2e7d74f9..3ec5fde38f4 100644 --- a/addons/crm/tests/test_crm_lead.py +++ b/addons/crm/tests/test_crm_lead.py @@ -78,7 +78,6 @@ class TestCRMLead(TestCrmCommon): subtype_xmlid='mail.mt_comment') self.assertEqual(message.author_id, self.user_sales_manager.partner_id) - new_partner_id = new_lead.handle_partner_assignation(action='create')[new_lead.id] - new_partner = self.env['res.partner'].with_user(self.user_sales_manager).browse(new_partner_id) - self.assertEqual(new_partner.email, 'unknown.sender@test.example.com') - self.assertEqual(new_partner.team_id, self.sales_team_1) + new_lead.handle_partner_assignment(create_missing=True) + self.assertEqual(new_lead.partner_id.email, 'unknown.sender@test.example.com') + self.assertEqual(new_lead.partner_id.team_id, self.sales_team_1) diff --git a/addons/crm/tests/test_crm_lead_convert.py b/addons/crm/tests/test_crm_lead_convert.py index 7e589598c41..75801e4a39f 100644 --- a/addons/crm/tests/test_crm_lead_convert.py +++ b/addons/crm/tests/test_crm_lead_convert.py @@ -8,6 +8,10 @@ from odoo.tests.common import tagged, users @tagged('lead_manage') class TestLeadConvert(crm_common.TestLeadConvertCommon): + """ + TODO: created partner (handle assignation) has team of lead + TODO: create partner has user_id coming from wizard + """ @classmethod def setUpClass(cls): @@ -15,6 +19,14 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): date = Datetime.from_string('2020-01-20 16:00:00') cls.crm_lead_dt_mock.now.return_value = date + def test_initial_data(self): + """ Ensure initial data to avoid spaghetti test update afterwards """ + self.assertFalse(self.lead_1.date_conversion) + self.assertEqual(self.lead_1.date_open, Datetime.from_string('2020-01-15 11:30:00')) + self.assertEqual(self.lead_1.user_id, self.user_sales_leads) + self.assertEqual(self.lead_1.team_id, self.sales_team_1) + self.assertEqual(self.lead_1.stage_id, self.stage_team1_1) + @users('user_sales_manager') def test_lead_convert_base(self): """ Test base method ``convert_opportunity`` or crm.lead model """ @@ -91,24 +103,21 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): other_lead = self.lead_1.copy() other_lead.write({'partner_id': self.contact_1.id}) - # TDE FIXME: default_lead_id should be correctly supported by wizard -> currently completely broken - # convert = self.env['crm.lead2opportunity.partner'].with_context({ - # 'default_lead_id': other_lead.id, - # }).create({}) - # self.assertEqual(convert.lead_id, other_lead) - # self.assertEqual(convert.partner_id, self.contact_1) - # self.assertEqual(convert.action, 'exist') + convert = self.env['crm.lead2opportunity.partner'].with_context({ + 'default_lead_id': other_lead.id, + }).create({}) + self.assertEqual(convert.lead_id, other_lead) + self.assertEqual(convert.partner_id, self.contact_1) + self.assertEqual(convert.action, 'exist') convert = self.env['crm.lead2opportunity.partner'].with_context({ 'default_lead_id': other_lead.id, 'active_model': 'crm.lead', 'active_id': self.lead_1.id, }).create({}) - # TDE FIXME: active_id should be a fallback, not the only one way to work with a standard wizard - self.assertEqual(convert.lead_id, self.lead_1) - # self.assertEqual(convert.lead_id, other_lead) - # self.assertEqual(convert.partner_id, self.contact_1) - # self.assertEqual(convert.action, 'exist') + self.assertEqual(convert.lead_id, other_lead) + self.assertEqual(convert.partner_id, self.contact_1) + self.assertEqual(convert.action, 'exist') @users('user_sales_manager') def test_lead_convert_corner_cases_matching(self): @@ -133,13 +142,6 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): @users('user_sales_manager') def test_lead_convert_internals(self): """ Test internals of convert wizard """ - # ensure initial data to avoid spaghetti test update afterwards - self.assertFalse(self.lead_1.date_conversion) - self.assertEqual(self.lead_1.date_open, Datetime.from_string('2020-01-15 11:30:00')) - self.assertEqual(self.lead_1.user_id, self.user_sales_leads) - self.assertEqual(self.lead_1.team_id, self.sales_team_1) - self.assertEqual(self.lead_1.stage_id, self.stage_team1_1) - convert = self.env['crm.lead2opportunity.partner'].with_context({ 'active_model': 'crm.lead', 'active_id': self.lead_1.id, @@ -155,7 +157,6 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): self.assertEqual(convert.action, 'create') convert.write({'user_id': self.user_sales_salesman.id}) - convert._onchange_user() self.assertEqual(convert.user_id, self.user_sales_salesman) self.assertEqual(convert.team_id, self.sales_team_convert) @@ -226,7 +227,7 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): }).create({}) # test internals of convert wizard - self.assertEqual(convert.opportunity_ids, self.lead_1 | leads) + self.assertEqual(convert.duplicated_lead_ids, self.lead_1 | leads) self.assertEqual(convert.user_id, self.lead_1.user_id) self.assertEqual(convert.team_id, self.lead_1.team_id) self.assertFalse(convert.partner_id) @@ -234,7 +235,6 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): self.assertEqual(convert.action, 'create') convert.write({'user_id': self.user_sales_salesman.id}) - convert._onchange_user() self.assertEqual(convert.user_id, self.user_sales_salesman) self.assertEqual(convert.team_id, self.sales_team_convert) @@ -243,7 +243,7 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): @users('user_sales_manager') def test_lead_merge_duplicates(self): - """ Test Lead._get_duplicated_leads_by_emails() """ + """ Test Lead._get_lead_duplicates() """ # Check: partner / email fallbacks self._create_duplicates(self.lead_1) @@ -257,9 +257,8 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): 'active_ids': self.lead_1.ids, }).create({}) self.assertEqual(convert.partner_id, self.customer) - # TDE FIXME: should not give priority to partner email if it is void - # self.assertEqual(convert.opportunity_ids, self.lead_1 | lead_email_from | lead_email_normalized | lead_partner) - self.assertEqual(convert.opportunity_ids, self.env['crm.lead']) + # self.assertEqual(convert.duplicated_lead_ids, self.lead_1 | self.lead_email_from | self.lead_email_normalized | self.lead_partner | self.opp_lost) + self.assertEqual(convert.duplicated_lead_ids, self.lead_1 | self.lead_email_from | self.lead_partner | self.opp_lost) # Check: partner fallbacks self.lead_1.write({ @@ -273,13 +272,11 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): 'active_ids': self.lead_1.ids, }).create({}) self.assertEqual(convert.partner_id, self.customer) - # TDE FIXME: CHECKME: 2917b38f28d5c2d6c53c706e613da7b8e2ad7b52 - # self.assertEqual(convert.opportunity_ids, self.lead_1 | lead_partner) - self.assertEqual(convert.opportunity_ids, self.env['crm.lead']) + self.assertEqual(convert.duplicated_lead_ids, self.lead_1 | self.lead_partner) @users('user_sales_manager') def test_lead_merge_duplicates_flow(self): - """ Test Lead._get_duplicated_leads_by_emails() + merge with active_test """ + """ Test Lead._get_lead_duplicates() + merge with active_test """ # Check: email formatting self.lead_1.write({ @@ -294,9 +291,8 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): }).create({}) self.assertEqual(convert.partner_id, self.customer) # TDE FIXME: should check for email_normalized -> lead_email_normalized not correctly found - # self.assertEqual(convert.opportunity_ids, self.lead_1 | lead_email_from | lead_email_normalized | lead_partner | opp_lost) - # TDE FIXME: add a active test context on opportunity_ids fields - self.assertEqual(convert.with_context(active_test=False).opportunity_ids, self.lead_1 | self.lead_email_from | self.lead_partner | self.opp_lost) + # self.assertEqual(convert.duplicated_lead_ids, self.lead_1 | lead_email_from | lead_email_normalized | lead_partner | opp_lost) + self.assertEqual(convert.duplicated_lead_ids, self.lead_1 | self.lead_email_from | self.lead_partner | self.opp_lost) convert.action_apply() self.assertEqual( @@ -308,6 +304,34 @@ class TestLeadConvert(crm_common.TestLeadConvertCommon): @tagged('lead_manage') class TestLeadConvertBatch(crm_common.TestLeadConvertMassCommon): + def test_initial_data(self): + """ Ensure initial data to avoid spaghetti test update afterwards """ + self.assertFalse(self.lead_1.date_conversion) + self.assertEqual(self.lead_1.date_open, Datetime.from_string('2020-01-15 11:30:00')) + self.assertEqual(self.lead_1.user_id, self.user_sales_leads) + self.assertEqual(self.lead_1.team_id, self.sales_team_1) + self.assertEqual(self.lead_1.stage_id, self.stage_team1_1) + + self.assertEqual(self.lead_w_partner.stage_id, self.env['crm.stage']) + self.assertEqual(self.lead_w_partner.user_id, self.user_sales_manager) + self.assertEqual(self.lead_w_partner.team_id, self.sales_team_1) + + self.assertEqual(self.lead_w_partner_company.stage_id, self.stage_team1_1) + self.assertEqual(self.lead_w_partner_company.user_id, self.user_sales_manager) + self.assertEqual(self.lead_w_partner_company.team_id, self.sales_team_1) + + self.assertEqual(self.lead_w_contact.stage_id, self.stage_gen_1) + self.assertEqual(self.lead_w_contact.user_id, self.user_sales_salesman) + self.assertEqual(self.lead_w_contact.team_id, self.sales_team_convert) + + self.assertEqual(self.lead_w_email.stage_id, self.stage_gen_1) + self.assertEqual(self.lead_w_email.user_id, self.user_sales_salesman) + self.assertEqual(self.lead_w_email.team_id, self.sales_team_convert) + + self.assertEqual(self.lead_w_email_lost.stage_id, self.stage_team1_2) + self.assertEqual(self.lead_w_email_lost.user_id, self.user_sales_leads) + self.assertEqual(self.lead_w_email_lost.team_id, self.sales_team_1) + @users('user_sales_manager') def test_lead_convert_batch_internals(self): """ Test internals of convert wizard, working in batch mode """ @@ -315,17 +339,8 @@ class TestLeadConvertBatch(crm_common.TestLeadConvertMassCommon): self.crm_lead_dt_mock.now.return_value = date lead_w_partner = self.lead_w_partner - self.assertEqual(lead_w_partner.user_id, self.user_sales_manager) - self.assertEqual(lead_w_partner.team_id, self.sales_team_1) - self.assertEqual(lead_w_partner.stage_id, self.env['crm.stage']) lead_w_contact = self.lead_w_contact - self.assertEqual(lead_w_contact.user_id, self.user_sales_salesman) - self.assertEqual(lead_w_contact.team_id, self.sales_team_convert) - self.assertEqual(lead_w_contact.stage_id, self.stage_gen_1) lead_w_email_lost = self.lead_w_email_lost - self.assertEqual(lead_w_email_lost.user_id, self.user_sales_leads) - self.assertEqual(lead_w_email_lost.team_id, self.sales_team_1) - self.assertEqual(lead_w_email_lost.stage_id, self.stage_team1_2) lead_w_email_lost.action_set_lost() self.assertEqual(lead_w_email_lost.active, False) @@ -397,10 +412,8 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): }).create({ 'deduplicate': False, 'user_id': self.user_sales_salesman.id, - 'force_assignation': False, + 'force_assignment': False, }) - mass_convert.onchange_action() - mass_convert._onchange_user() # default values self.assertEqual(mass_convert.name, 'convert') @@ -411,7 +424,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): self.assertEqual(mass_convert.user_id, self.user_sales_salesman) self.assertEqual(mass_convert.team_id, self.sales_team_convert) - mass_convert.mass_convert() + mass_convert.action_mass_convert() for lead in self.lead_1 | self.lead_w_partner: self.assertEqual(lead.type, 'opportunity') if lead == self.lead_w_partner: @@ -427,7 +440,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): mass_convert.write({ 'user_ids': self.user_sales_salesman.ids, }) - mass_convert.mass_convert() + mass_convert.action_mass_convert() self.assertEqual(self.lead_w_partner.user_id, self.user_sales_salesman) self.assertEqual(self.lead_1.user_id, self.user_sales_leads) # existing value not forced @@ -439,7 +452,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): # self.assertEqual(self.lead_w_email_lost.partner_id, self.env['res.partner']) def test_mass_convert_deduplicate(self): - """ Test opportunity_ids fields having another behavior in mass convert + """ Test duplicated_lead_ids fields having another behavior in mass convert because why not. Its use is: among leads under convert, store those with duplicates if deduplicate is set to True. """ lead_1_dups = self._create_duplicates(self.lead_1, create_opp=False) @@ -456,12 +469,12 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): }).create({ 'deduplicate': True, }) - mass_convert._onchange_deduplicate() self.assertEqual(mass_convert.action, 'each_exist_or_create') self.assertEqual(mass_convert.name, 'convert') - self.assertEqual(mass_convert.opportunity_ids, self.lead_1 | self.lead_w_partner) + self.assertEqual(mass_convert.lead_tomerge_ids, self.leads) + self.assertEqual(mass_convert.duplicated_lead_ids, self.lead_1 | self.lead_w_partner) - mass_convert.mass_convert() + mass_convert.action_mass_convert() self.assertEqual( (lead_1_dups | lead_w_partner_dups).exists(), @@ -484,11 +497,11 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): }).create({ 'deduplicate': False, 'user_ids': self.assign_users.ids, - 'force_assignation': True, + 'force_assignment': True, }) # TDE FIXME: what happens if we mix people from different sales team ? currently nothing, to check - mass_convert.mass_convert() + mass_convert.action_mass_convert() for idx, lead in enumerate(self.leads - self.lead_w_email_lost): self.assertEqual(lead.type, 'opportunity') diff --git a/addons/crm/tests/test_crm_lead_merge.py b/addons/crm/tests/test_crm_lead_merge.py index 22d103f4f1f..1bcf1c2a640 100644 --- a/addons/crm/tests/test_crm_lead_merge.py +++ b/addons/crm/tests/test_crm_lead_merge.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.crm.tests.common import TestLeadConvertMassCommon +from odoo.fields import Datetime from odoo.tests.common import tagged, users @@ -17,8 +18,9 @@ class TestLeadMerge(TestLeadConvertMassCommon): cls.leads = cls.lead_1 + cls.lead_w_partner + cls.lead_w_contact + cls.lead_w_email + cls.lead_w_partner_company + cls.lead_w_email_lost # reset some assigned users to test salesmen assign (cls.lead_w_partner | cls.lead_w_email_lost).write({ - 'user_id': False + 'user_id': False, }) + cls.lead_w_partner.write({'stage_id': False}) cls.lead_w_contact.write({'description': 'lead_w_contact'}) cls.lead_w_email.write({'description': 'lead_w_email'}) @@ -27,6 +29,34 @@ class TestLeadMerge(TestLeadConvertMassCommon): cls.assign_users = cls.user_sales_manager + cls.user_sales_leads_convert + cls.user_sales_salesman + def test_initial_data(self): + """ Ensure initial data to avoid spaghetti test update afterwards """ + self.assertFalse(self.lead_1.date_conversion) + self.assertEqual(self.lead_1.date_open, Datetime.from_string('2020-01-15 11:30:00')) + self.assertEqual(self.lead_1.user_id, self.user_sales_leads) + self.assertEqual(self.lead_1.team_id, self.sales_team_1) + self.assertEqual(self.lead_1.stage_id, self.stage_team1_1) + + self.assertEqual(self.lead_w_partner.stage_id, self.env['crm.stage']) + self.assertEqual(self.lead_w_partner.user_id, self.env['res.users']) + self.assertEqual(self.lead_w_partner.team_id, self.sales_team_1) + + self.assertEqual(self.lead_w_partner_company.stage_id, self.stage_team1_1) + self.assertEqual(self.lead_w_partner_company.user_id, self.user_sales_manager) + self.assertEqual(self.lead_w_partner_company.team_id, self.sales_team_1) + + self.assertEqual(self.lead_w_contact.stage_id, self.stage_gen_1) + self.assertEqual(self.lead_w_contact.user_id, self.user_sales_salesman) + self.assertEqual(self.lead_w_contact.team_id, self.sales_team_convert) + + self.assertEqual(self.lead_w_email.stage_id, self.stage_gen_1) + self.assertEqual(self.lead_w_email.user_id, self.user_sales_salesman) + self.assertEqual(self.lead_w_email.team_id, self.sales_team_convert) + + self.assertEqual(self.lead_w_email_lost.stage_id, self.stage_team1_2) + self.assertEqual(self.lead_w_email_lost.user_id, self.env['res.users']) + self.assertEqual(self.lead_w_email_lost.team_id, self.sales_team_1) + @users('user_sales_manager') def test_lead_merge_internals(self): """ Test internals of merge wizard. In this test leads are ordered as @@ -37,9 +67,6 @@ class TestLeadMerge(TestLeadConvertMassCommon): lead_w_partner --lead---seq=False """ # ensure initial data - self.assertEqual(self.lead_1.user_id, self.user_sales_leads) - self.assertEqual(self.lead_1.team_id, self.sales_team_1) - self.assertEqual(self.lead_1.stage_id, self.stage_team1_1) self.lead_w_partner_company.action_set_won() # won opps should be excluded merge = self.env['crm.merge.opportunity'].with_context({ @@ -49,8 +76,6 @@ class TestLeadMerge(TestLeadConvertMassCommon): }).create({ 'user_id': self.user_sales_leads_convert.id, }) - # check user correctly triggered its sales team - merge._onchange_user() self.assertEqual(merge.team_id, self.sales_team_convert) # TDE FIXME: not sure the browse in default get of wizard intended to exlude lost, as it browse ids @@ -96,7 +121,6 @@ class TestLeadMerge(TestLeadConvertMassCommon): 'user_id': False, }) # TDE FIXME: see aa44700dccdc2618e0b8bc94252789264104047c -> no user, no team -> strange - merge._onchange_user() merge.write({'team_id': self.sales_team_convert.id}) # TDE FIXME: not sure the browse in default get of wizard intended to exlude lost, as it browse ids @@ -113,7 +137,7 @@ class TestLeadMerge(TestLeadConvertMassCommon): # merged opportunity has same salesman (not updated in wizard) self.assertEqual(merge_opportunity.user_id, self.user_sales_leads) # TDE FIXME: as same uer_id is enforced, team is updated through onchange and therefore stage - # self.assertEqual(merge_opportunity.team_id, self.sales_team_convert) - self.assertEqual(merge_opportunity.team_id, self.sales_team_1) + self.assertEqual(merge_opportunity.team_id, self.sales_team_convert) + # self.assertEqual(merge_opportunity.team_id, self.sales_team_1) # TDE FIXME: BUT team_id is computed after checking stage, based on wizard's team_id self.assertEqual(merge_opportunity.stage_id, self.stage_team_convert_1) diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 4b177ca273a..89459f4d434 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -172,7 +172,7 @@ + context="{'default_sales_team_id': team_id}"/> diff --git a/addons/crm/views/crm_team_views.xml b/addons/crm/views/crm_team_views.xml index 038b69028b6..90301bc7385 100644 --- a/addons/crm/views/crm_team_views.xml +++ b/addons/crm/views/crm_team_views.xml @@ -191,11 +191,11 @@ - -
+ diff --git a/addons/crm/wizard/crm_lead_to_opportunity.py b/addons/crm/wizard/crm_lead_to_opportunity.py index e4e101298ec..2d7b8b77489 100644 --- a/addons/crm/wizard/crm_lead_to_opportunity.py +++ b/addons/crm/wizard/crm_lead_to_opportunity.py @@ -12,148 +12,159 @@ class Lead2OpportunityPartner(models.TransientModel): @api.model def default_get(self, fields): - """ Default get for name, opportunity_ids. - If there is an exisitng partner link to the lead, find all existing - opportunities links with this partner to merge all information together - """ + + """ Allow support of active_id / active_model instead of jut default_lead_id + to ease window action definitions, and be backward compatible. """ result = super(Lead2OpportunityPartner, self).default_get(fields) - if self._context.get('active_id'): - tomerge = {int(self._context['active_id'])} - - lead = self.env['crm.lead'].browse(self._context['active_id']) - result['lead_id'] = lead.id - - partner_id = lead._find_matching_partner() - email = lead.partner_id.email if lead.partner_id else lead.email_from - - tomerge.update(self.env['crm.lead']._get_duplicated_leads_by_emails(partner_id, email, include_lost=True).ids) - - if 'action' in fields and not result.get('action'): - result['action'] = 'exist' if partner_id else 'create' - if 'partner_id' in fields: - result['partner_id'] = partner_id - if 'name' in fields: - result['name'] = 'merge' if len(tomerge) >= 2 else 'convert' - if 'opportunity_ids' in fields and len(tomerge) >= 2: - result['opportunity_ids'] = list(tomerge) - if lead.user_id: - result['user_id'] = lead.user_id.id - if lead.team_id: - result['team_id'] = lead.team_id.id - if not partner_id and not lead.contact_name: - result['action'] = 'nothing' + if not result.get('lead_id') and self.env.context.get('active_id'): + result['lead_id'] = self.env.context.get('active_id') return result name = fields.Selection([ ('convert', 'Convert to opportunity'), ('merge', 'Merge with existing opportunities') - ], 'Conversion Action', required=True) + ], 'Conversion Action', compute='_compute_name', readonly=False, store=True) action = fields.Selection([ ('create', 'Create a new customer'), ('exist', 'Link to an existing customer'), ('nothing', 'Do not link to a customer') - ], string='Related Customer', required=True) - lead_id = fields.Many2one('crm.lead', "Associated Lead") - opportunity_ids = fields.Many2many('crm.lead', string='Opportunities') - partner_id = fields.Many2one('res.partner', 'Customer') - user_id = fields.Many2one('res.users', 'Salesperson') - team_id = fields.Many2one('crm.team', 'Sales Team') + ], string='Related Customer', compute='_compute_action', readonly=False, store=True) + lead_id = fields.Many2one('crm.lead', 'Associated Lead', required=True) + duplicated_lead_ids = fields.Many2many( + 'crm.lead', string='Opportunities', context={'active_test': False}, + compute='_compute_duplicated_lead_ids', readonly=False, store=True) + partner_id = fields.Many2one( + 'res.partner', 'Customer', + compute='_compute_partner_id', readonly=False, store=True) + user_id = fields.Many2one( + 'res.users', 'Salesperson', + compute='_compute_user_id', readonly=False, store=True) + team_id = fields.Many2one( + 'crm.team', 'Sales Team', + compute='_compute_team_id', readonly=False, store=True) + force_assignment = fields.Boolean( + 'Force assignment', default=True, + help='If checked, forces salesman to be updated on updated opportunities even if already set.') - @api.onchange('action') - def onchange_action(self): - if self.action == 'exist': - self.partner_id = self.lead_id._find_matching_partner() - else: - self.partner_id = False + @api.depends('duplicated_lead_ids') + def _compute_name(self): + for convert in self: + convert.name = 'merge' if convert.duplicated_lead_ids and len(convert.duplicated_lead_ids) >= 2 else 'convert' - @api.onchange('user_id') - def _onchange_user(self): - """ When changing the user, also set a team_id or restrict team id - to the ones user_id is member of. - """ - if self.user_id: - if self.team_id: - user_in_team = self.env['crm.team'].search_count([('id', '=', self.team_id.id), '|', ('user_id', '=', self.user_id.id), ('member_ids', '=', self.user_id.id)]) + @api.depends('lead_id') + def _compute_action(self): + for convert in self: + if not convert.lead_id: + convert.action = 'nothing' else: - user_in_team = False - if not user_in_team: - values = self.env['crm.lead']._onchange_user_values(self.user_id.id if self.user_id else False) - self.team_id = values.get('team_id', False) + partner = convert.lead_id._find_matching_partner() + if partner: + convert.action = 'exist' + elif convert.lead_id.contact_name: + convert.action = 'create' + else: + convert.action = 'nothing' + + @api.depends('lead_id', 'partner_id') + def _compute_duplicated_lead_ids(self): + for convert in self: + if not convert.lead_id: + convert.duplicated_lead_ids = False + continue + convert.duplicated_lead_ids = self.env['crm.lead']._get_lead_duplicates( + convert.partner_id, + convert.lead_id.partner_id.email if convert.lead_id.partner_id.email else convert.lead_id.email_from, + include_lost=True).ids + + @api.depends('action') + def _compute_partner_id(self): + for convert in self: + if convert.action == 'exist': + convert.partner_id = convert.lead_id._find_matching_partner() + else: + convert.partner_id = False + + @api.depends('lead_id') + def _compute_user_id(self): + for convert in self: + convert.user_id = convert.lead_id.user_id if convert.lead_id.user_id else False + + @api.depends('user_id') + def _compute_team_id(self): + """ When changing the user, also set a team_id or restrict team id + to the ones user_id is member of. """ + for convert in self: + # setting user as void should not trigger a new team computation + if not convert.user_id: + continue + 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) + convert.team_id = team.id - # NOTE JEM : is it the good place to test this ? @api.model def view_init(self, fields): + # JEM TDE FIXME: clean that brol """ Check some preconditions before the wizard executes. """ for lead in self.env['crm.lead'].browse(self._context.get('active_ids', [])): if lead.probability == 100: raise UserError(_("Closed/Dead leads cannot be converted into opportunities.")) return False - def _convert_opportunity(self, vals): + def action_apply(self): + if self.name == 'merge': + result_opportunity = self._action_merge() + else: + result_opportunity = self._action_convert() + + return result_opportunity.redirect_lead_opportunity_view() + + def _action_merge(self): + to_merge = self.duplicated_lead_ids + result_opportunity = to_merge.merge_opportunity(auto_unlink=False) + if not result_opportunity.active: + result_opportunity.write({'active': True, 'activity_type_id': False, 'lost_reason': False}) + + if result_opportunity.type == "lead": + self._convert_and_allocate(result_opportunity, [self.user_id.id], team_id=self.team_id.id) + else: + if not result_opportunity.user_id or self.force_assignment: + result_opportunity.write({ + 'user_id': self.user_id.id, + 'team_id': self.team_id.id, + }) + (to_merge - result_opportunity).unlink() + return result_opportunity + + def _action_convert(self): + """ """ + result_opportunities = self.env['crm.lead'].browse(self._context.get('active_ids', [])) + self._convert_and_allocate(result_opportunities, [self.user_id.id], team_id=self.team_id.id) + return result_opportunities[0] + + def _convert_and_allocate(self, leads, user_ids, team_id=False): self.ensure_one() - res = False - - leads = self.env['crm.lead'].browse(vals.get('lead_ids')) for lead in leads: - self_def_user = self.with_context(default_user_id=self.user_id.id) + if lead.active and self.action != 'nothing': + self._convert_handle_partner( + lead, self.action, self.partner_id.id or lead.partner_id.id) - partner_id = False - if self.action != 'nothing': - partner_id = self_def_user._create_partner( - lead.id, self.action, vals.get('partner_id') or lead.partner_id.id) - - res = lead.convert_opportunity(partner_id, [], False) - user_ids = vals.get('user_ids') + lead.convert_opportunity(lead.partner_id.id, [], False) leads_to_allocate = leads - if self._context.get('no_force_assignation'): + if not self.force_assignment: leads_to_allocate = leads_to_allocate.filtered(lambda lead: not lead.user_id) if user_ids: - leads_to_allocate.allocate_salesman(user_ids, team_id=(vals.get('team_id'))) + leads_to_allocate.handle_salesmen_assignment(user_ids, team_id=team_id) - return res - - def action_apply(self): - """ Convert lead to opportunity or merge lead and opportunity and open - the freshly created opportunity view. - """ - self.ensure_one() - values = { - 'team_id': self.team_id.id, - } - - if self.partner_id: - values['partner_id'] = self.partner_id.id - - if self.name == 'merge': - leads = self.with_context(active_test=False).opportunity_ids.merge_opportunity() - if not leads.active: - leads.write({'active': True, 'activity_type_id': False, 'lost_reason': False}) - if leads.type == "lead": - values.update({'lead_ids': leads.ids, 'user_ids': [self.user_id.id]}) - self.with_context(active_ids=leads.ids)._convert_opportunity(values) - elif not self._context.get('no_force_assignation') or not leads.user_id: - values['user_id'] = self.user_id.id - leads.write(values) - else: - leads = self.env['crm.lead'].browse(self._context.get('active_ids', [])) - values.update({'lead_ids': leads.ids, 'user_ids': [self.user_id.id]}) - self._convert_opportunity(values) - - return leads[0].redirect_lead_opportunity_view() - - def _create_partner(self, lead_id, action, partner_id): - """ Create partner based on action. - :return dict: dictionary organized as followed: {lead_id: partner_assigned_id} - """ - #TODO this method in only called by Lead2OpportunityPartner - #wizard and would probably diserve to be refactored or at least - #moved to a better place - if action == 'each_exist_or_create': - partner_id = self.env['crm.lead'].browse(lead_id)._find_matching_partner() - action = 'create' - result = self.env['crm.lead'].browse(lead_id).handle_partner_assignation(action, partner_id) - return result.get(lead_id) + def _convert_handle_partner(self, lead, action, partner_id): + # used to propagate user_id (salesman) on created partners during conversion + lead.with_context(default_user_id=self.user_id.id).handle_partner_assignment( + force_partner_id=partner_id, + create_missing=(action == 'create') + ) diff --git a/addons/crm/wizard/crm_lead_to_opportunity_mass.py b/addons/crm/wizard/crm_lead_to_opportunity_mass.py index af5d8226443..f33abf83ea9 100644 --- a/addons/crm/wizard/crm_lead_to_opportunity_mass.py +++ b/addons/crm/wizard/crm_lead_to_opportunity_mass.py @@ -5,7 +5,6 @@ from odoo import api, fields, models class Lead2OpportunityMassConvert(models.TransientModel): - _name = 'crm.lead2opportunity.partner.mass' _description = 'Convert Lead to Opportunity (in mass)' _inherit = 'crm.lead2opportunity.partner' @@ -13,70 +12,87 @@ class Lead2OpportunityMassConvert(models.TransientModel): @api.model def default_get(self, fields): res = super(Lead2OpportunityMassConvert, self).default_get(fields) - if 'partner_id' in fields: # avoid forcing the partner of the first lead as default - res['partner_id'] = False - if 'action' in fields: - res['action'] = 'each_exist_or_create' - if 'name' in fields: - res['name'] = 'convert' - if 'opportunity_ids' in fields: - res['opportunity_ids'] = False + if 'lead_tomerge_ids' in fields and not res.get('lead_tomerge_ids'): + res['lead_tomerge_ids'] = self.env.context.get('active_ids', []) return res + lead_id = fields.Many2one(required=False) + lead_tomerge_ids = fields.Many2many( + 'crm.lead', 'crm_convert_lead_mass_lead_rel', + string='Active Leads', context={'active_test': False}) user_ids = fields.Many2many('res.users', string='Salesmen') - team_id = fields.Many2one('crm.team', 'Sales Team', index=True) deduplicate = fields.Boolean('Apply deduplication', default=True, help='Merge with existing leads/opportunities of each partner') action = fields.Selection(selection_add=[ ('each_exist_or_create', 'Use existing partner or create'), - ], string='Related Customer', required=True) - force_assignation = fields.Boolean('Force assignment', help='If unchecked, this will leave the salesman of duplicated opportunities') + ], string='Related Customer') + force_assignment = fields.Boolean(default=False) - @api.onchange('action') - def _onchange_action(self): - if self.action != 'exist': - self.partner_id = False + @api.depends('duplicated_lead_ids') + def _compute_name(self): + for convert in self: + convert.name = 'convert' - @api.onchange('deduplicate') - def _onchange_deduplicate(self): - active_leads = self.env['crm.lead'].browse(self._context['active_ids']) - partner_ids = [(lead.partner_id.id, lead.partner_id and lead.partner_id.email or lead.email_from) for lead in active_leads] - partners_duplicated_leads = {} - for partner_id, email in partner_ids: - duplicated_leads = self.env['crm.lead']._get_duplicated_leads_by_emails(partner_id, email, include_lost=False) - if len(duplicated_leads) > 1: - partners_duplicated_leads.setdefault((partner_id, email), []).extend(duplicated_leads) + @api.depends('lead_tomerge_ids') + def _compute_action(self): + for convert in self: + convert.action = 'each_exist_or_create' - leads_with_duplicates = [] - for lead in active_leads: - lead_tuple = (lead.partner_id.id, lead.partner_id.email if lead.partner_id else lead.email_from) - if len(partners_duplicated_leads.get(lead_tuple, [])) > 1: - leads_with_duplicates.append(lead.id) + @api.depends('lead_tomerge_ids') + def _compute_partner_id(self): + for convert in self: + convert.partner_id = False - self.opportunity_ids = self.env['crm.lead'].browse(leads_with_duplicates) + @api.depends('user_ids') + def _compute_team_id(self): + """ When changing the user, also set a team_id or restrict team id + to the ones user_id is member of. """ + for convert in self: + # setting user as void should not trigger a new team computation + if not convert.user_id and not convert.user_ids and convert.team_id: + continue + 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) + convert.team_id = team.id - def _convert_opportunity(self, vals): + @api.depends('lead_tomerge_ids') + def _compute_duplicated_lead_ids(self): + for convert in self: + duplicated = self.env['crm.lead'] + for lead in convert.lead_tomerge_ids: + duplicated_leads = self.env['crm.lead']._get_lead_duplicates( + partner=lead.partner_id, + email=lead.partner_id and lead.partner_id.email or lead.email_from, + include_lost=False) + if len(duplicated_leads) > 1: + duplicated += lead + convert.duplicated_lead_ids = duplicated.ids + + def _convert_and_allocate(self, leads, user_ids, team_id=False): """ When "massively" (more than one at a time) converting leads to - opportunities, check the salesteam_id and salesmen_ids and update - the values before calling super. + opportunities, check the salesteam_id and salesmen_ids and update + the values before calling super. """ self.ensure_one() - salesteam_id = self.team_id.id if self.team_id else False salesmen_ids = [] if self.user_ids: salesmen_ids = self.user_ids.ids - vals.update({'user_ids': salesmen_ids, 'team_id': salesteam_id}) - return super(Lead2OpportunityMassConvert, self)._convert_opportunity(vals) + return super(Lead2OpportunityMassConvert, self)._convert_and_allocate(leads, salesmen_ids, team_id=team_id) - def mass_convert(self): + def action_mass_convert(self): self.ensure_one() if self.name == 'convert' and self.deduplicate: merged_lead_ids = set() remaining_lead_ids = set() - lead_selected = self._context.get('active_ids', []) - for lead_id in lead_selected: - if lead_id not in merged_lead_ids: - lead = self.env['crm.lead'].browse(lead_id) - duplicated_leads = self.env['crm.lead']._get_duplicated_leads_by_emails(lead.partner_id.id, lead.partner_id.email if lead.partner_id else lead.email_from, include_lost=False) + for lead in self.lead_tomerge_ids: + if lead not in merged_lead_ids: + duplicated_leads = self.env['crm.lead']._get_lead_duplicates( + partner=lead.partner_id, + email=lead.partner_id.email or lead.email_from, + include_lost=False + ) if len(duplicated_leads) > 1: lead = duplicated_leads.merge_opportunity() merged_lead_ids.update(duplicated_leads.ids) @@ -85,5 +101,10 @@ class Lead2OpportunityMassConvert(models.TransientModel): active_ids = (active_ids - merged_lead_ids) | remaining_lead_ids self = self.with_context(active_ids=list(active_ids)) # only update active_ids when there are set - no_force_assignation = self._context.get('no_force_assignation', not self.force_assignation) - return self.with_context(no_force_assignation=no_force_assignation).action_apply() + return self.action_apply() + + def _convert_handle_partner(self, lead, action, partner_id): + if self.action == 'each_exist_or_create': + partner_id = lead._find_matching_partner().id + action = 'create' + return super(Lead2OpportunityMassConvert, self)._convert_handle_partner(lead, action, partner_id) diff --git a/addons/crm/wizard/crm_lead_to_opportunity_mass_views.xml b/addons/crm/wizard/crm_lead_to_opportunity_mass_views.xml index e670e7b0053..d83c27b045a 100644 --- a/addons/crm/wizard/crm_lead_to_opportunity_mass_views.xml +++ b/addons/crm/wizard/crm_lead_to_opportunity_mass_views.xml @@ -5,6 +5,7 @@ crm.lead2opportunity.partner.mass
+ @@ -13,11 +14,11 @@ - + -
-
@@ -55,7 +56,7 @@ res_model="crm.lead2opportunity.partner.mass" binding_model="crm.lead" binding_views="list" view_mode="form" target="new" - context="{'mass_convert' : True}" + context="{}" view_id="view_crm_lead2opportunity_partner_mass" /> diff --git a/addons/crm/wizard/crm_lead_to_opportunity_views.xml b/addons/crm/wizard/crm_lead_to_opportunity_views.xml index 6871a97baca..4534e4352cd 100644 --- a/addons/crm/wizard/crm_lead_to_opportunity_views.xml +++ b/addons/crm/wizard/crm_lead_to_opportunity_views.xml @@ -13,7 +13,7 @@ - + diff --git a/addons/crm/wizard/crm_merge_opportunities.py b/addons/crm/wizard/crm_merge_opportunities.py index 3693df8c0e4..7307bd37554 100644 --- a/addons/crm/wizard/crm_merge_opportunities.py +++ b/addons/crm/wizard/crm_merge_opportunities.py @@ -34,22 +34,23 @@ class MergeOpportunity(models.TransientModel): opportunity_ids = fields.Many2many('crm.lead', 'merge_opportunity_rel', 'merge_id', 'opportunity_id', string='Leads/Opportunities') user_id = fields.Many2one('res.users', 'Salesperson', index=True) - team_id = fields.Many2one('crm.team', 'Sales Team', index=True) + team_id = fields.Many2one( + 'crm.team', 'Sales Team', index=True, + compute='_compute_team_id', readonly=False, store=True) def action_merge(self): self.ensure_one() merge_opportunity = self.opportunity_ids.merge_opportunity(self.user_id.id, self.team_id.id) return merge_opportunity.redirect_lead_opportunity_view() - @api.onchange('user_id') - def _onchange_user(self): + @api.depends('user_id') + def _compute_team_id(self): """ When changing the user, also set a team_id or restrict team id to the ones user_id is member of. """ - team_id = False - if self.user_id: - user_in_team = False - if self.team_id: - user_in_team = self.env['crm.team'].search_count([('id', '=', self.team_id.id), '|', ('user_id', '=', self.user_id.id), ('member_ids', '=', self.user_id.id)]) - if not user_in_team: - team_id = self.env['crm.team'].search(['|', ('user_id', '=', self.user_id.id), ('member_ids', '=', self.user_id.id)], limit=1) - self.team_id = team_id + for wizard in self: + if wizard.user_id: + user_in_team = False + if wizard.team_id: + user_in_team = wizard.env['crm.team'].search_count([('id', '=', wizard.team_id.id), '|', ('user_id', '=', wizard.user_id.id), ('member_ids', '=', wizard.user_id.id)]) + if not user_in_team: + wizard.team_id = wizard.env['crm.team'].search(['|', ('user_id', '=', wizard.user_id.id), ('member_ids', '=', wizard.user_id.id)], limit=1) diff --git a/addons/crm_livechat/models/mail_channel.py b/addons/crm_livechat/models/mail_channel.py index 6df38d2317c..f608bd1a0dd 100644 --- a/addons/crm_livechat/models/mail_channel.py +++ b/addons/crm_livechat/models/mail_channel.py @@ -31,7 +31,7 @@ class MailChannel(models.Model): for message in self.channel_message_ids.sorted('id') ) utm_source = self.env.ref('crm_livechat.utm_source_livechat', raise_if_not_found=False) - lead = self.env['crm.lead'].create({ + return self.env['crm.lead'].create({ 'name': html2plaintext(key[5:]), 'partner_id': channel_partners.partner_id.id, 'user_id': None, @@ -40,5 +40,3 @@ class MailChannel(models.Model): 'referred': partner.name, 'source_id': utm_source and utm_source.id, }) - lead._onchange_partner_id() - return lead diff --git a/addons/sale_crm/wizard/crm_opportunity_to_quotation.py b/addons/sale_crm/wizard/crm_opportunity_to_quotation.py index 6ffa8860153..aef9a259ff8 100644 --- a/addons/sale_crm/wizard/crm_opportunity_to_quotation.py +++ b/addons/sale_crm/wizard/crm_opportunity_to_quotation.py @@ -24,7 +24,7 @@ class Opportunity2Quotation(models.TransientModel): lead = self.env['crm.lead'].browse(self._context['active_id']) if lead: result['lead_id'] = lead.id - partner_id = result.get('partner_id') or lead._find_matching_partner() + partner_id = result.get('partner_id') or lead._find_matching_partner().id if 'action' in fields and not result.get('action'): result['action'] = 'exist' if partner_id else 'create' if 'partner_id' in fields and not result.get('partner_id'): @@ -46,16 +46,5 @@ class Opportunity2Quotation(models.TransientModel): """ self.ensure_one() if self.action != 'nothing': - self.lead_id.write({ - 'partner_id': self.partner_id.id if self.action == 'exist' else self._create_partner() - }) - self.lead_id._onchange_partner_id() + self.lead_id.handle_partner_assignment(force_partner_id=self.partner_id.id, create_missing=(self.action == 'create')) return self.lead_id.action_new_quotation() - - def _create_partner(self): - """ Create partner based on action. - :return int: created res.partner id - """ - self.ensure_one() - result = self.lead_id.handle_partner_assignation(action='create') - return result.get(self.lead_id.id) diff --git a/addons/website_crm/models/website_visitor.py b/addons/website_crm/models/website_visitor.py index a6e8c669363..9b0b4c52c34 100644 --- a/addons/website_crm/models/website_visitor.py +++ b/addons/website_crm/models/website_visitor.py @@ -51,10 +51,8 @@ class WebsiteVisitor(models.Model): partners = sorted_leads.mapped('partner_id') if not partners: main_lead = self.lead_ids[0] - partner_id = main_lead.handle_partner_assignation(action='create')[main_lead.id] - if not main_lead.partner_id: - main_lead.partner_id = partner_id - self.partner_id = partner_id + main_lead.handle_partner_assignment(create_missing=True) + self.partner_id = main_lead.partner_id.id return True return check diff --git a/addons/website_crm_partner_assign/models/crm_lead.py b/addons/website_crm_partner_assign/models/crm_lead.py index 3c55bf5f86a..817169bb56e 100644 --- a/addons/website_crm_partner_assign/models/crm_lead.py +++ b/addons/website_crm_partner_assign/models/crm_lead.py @@ -9,6 +9,7 @@ from odoo.exceptions import AccessDenied, AccessError class CrmLead(models.Model): _inherit = "crm.lead" + partner_latitude = fields.Float('Geo Latitude', digits=(16, 5)) partner_longitude = fields.Float('Geo Longitude', digits=(16, 5)) partner_assigned_id = fields.Many2one('res.partner', 'Assigned Partner', tracking=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", help="Partner this case has been forwarded/assigned to.", index=True) @@ -18,22 +19,22 @@ class CrmLead(models.Model): 'lead_id', 'partner_id', string='Partner not interested') - date_assign = fields.Date('Partner Assignment Date', help="Last date this case was forwarded/assigned to a partner") + date_partner_assign = fields.Date( + 'Partner Assignment Date', compute='_compute_date_partner_assign', + copy=True, readonly=False, store=True, + help="Last date this case was forwarded/assigned to a partner") def _merge_data(self, fields): fields += ['partner_latitude', 'partner_longitude', 'partner_assigned_id', 'date_assign'] return super(CrmLead, self)._merge_data(fields) @api.onchange("partner_assigned_id") - def onchange_assign_id(self): - """This function updates the "assignation date" automatically, when manually assign a partner in the geo assign tab - """ - partner_assigned = self.partner_assigned_id - if not partner_assigned: - self.date_assign = False - else: - self.date_assign = fields.Date.context_today(self) - self.user_id = partner_assigned.user_id + def _compute_date_partner_assign(self): + for lead in self: + if not lead.partner_assigned_id: + lead.date_partner_assign = False + else: + lead.date_assign = fields.Date.context_today(lead) def assign_salesman_of_assigned_partner(self): salesmans_leads = {} @@ -64,9 +65,8 @@ class CrmLead(models.Model): lead.assign_geo_localize(lead.partner_latitude, lead.partner_longitude) partner = self.env['res.partner'].browse(partner_id) if partner.user_id: - lead.allocate_salesman(partner.user_id.ids, team_id=partner.team_id.id) - values = {'date_assign': fields.Date.context_today(lead), 'partner_assigned_id': partner_id} - lead.write(values) + lead.handle_salesmen_assignment(partner.user_id.ids, team_id=partner.team_id.id) + lead.write({'partner_assigned_id': partner_id}) return res def assign_geo_localize(self, latitude=False, longitude=False): diff --git a/addons/website_crm_partner_assign/models/res_partner.py b/addons/website_crm_partner_assign/models/res_partner.py index 93c45db11b4..bc4a0202d95 100644 --- a/addons/website_crm_partner_assign/models/res_partner.py +++ b/addons/website_crm_partner_assign/models/res_partner.py @@ -37,7 +37,9 @@ class ResPartnerActivation(models.Model): class ResPartner(models.Model): _inherit = "res.partner" - partner_weight = fields.Integer('Level Weight', default=0, tracking=True, + partner_weight = fields.Integer( + 'Level Weight', compute='_compute_partner_weight', + copy=True, readonly=False, store=True, tracking=True, help="This should be a numerical value greater than 0 which will decide the contention for this partner to take this lead/opportunity.") grade_id = fields.Many2one('res.partner.grade', 'Partner Level', tracking=True) grade_sequence = fields.Integer(related='grade_id.sequence', readonly=True, store=True) @@ -60,7 +62,7 @@ class ResPartner(models.Model): for partner in self: partner.implemented_count = len(partner.implemented_partner_ids.filtered('website_published')) - @api.onchange('grade_id') - def _onchange_grade_id(self): - grade = self.grade_id - self.partner_weight = grade.partner_weight if grade else 0 + @api.depends('grade_id.partner_weight') + def _compute_partner_weight(self): + for partner in self: + partner.partner_weight = partner.grade_id.partner_weight if partner.grade_id else 0 diff --git a/addons/website_crm_partner_assign/views/crm_lead_views.xml b/addons/website_crm_partner_assign/views/crm_lead_views.xml index 229290f92e7..161519522f2 100644 --- a/addons/website_crm_partner_assign/views/crm_lead_views.xml +++ b/addons/website_crm_partner_assign/views/crm_lead_views.xml @@ -39,7 +39,7 @@ - +