diff --git a/addons/crm/__manifest__.py b/addons/crm/__manifest__.py index 4b25fb7538e..1100d1f4214 100644 --- a/addons/crm/__manifest__.py +++ b/addons/crm/__manifest__.py @@ -20,6 +20,7 @@ 'web_tour', 'contacts', 'digest', + 'phone_validation', ], 'data': [ 'security/crm_security.xml', @@ -29,6 +30,7 @@ 'data/crm_stage_data.xml', 'data/crm_lead_data.xml', 'data/digest_data.xml', + 'data/crm_lead_prediction_data.xml', 'wizard/crm_lead_lost_views.xml', 'wizard/crm_lead_to_opportunity_views.xml', diff --git a/addons/crm/data/crm_lead_prediction_data.xml b/addons/crm/data/crm_lead_prediction_data.xml new file mode 100644 index 00000000000..20a432a11f1 --- /dev/null +++ b/addons/crm/data/crm_lead_prediction_data.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + Predictive Lead Scoring: Rebuild Frequencies table + + code + model._cron_update_automated_probabilities() + 1 + days + -1 + + + + diff --git a/addons/crm/models/__init__.py b/addons/crm/models/__init__.py index c51fbebbb55..0a441965aa8 100644 --- a/addons/crm/models/__init__.py +++ b/addons/crm/models/__init__.py @@ -9,3 +9,4 @@ from . import crm_team from . import res_config_settings from . import res_partner from . import digest +from . import crm_lead_scoring_frequency diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 4b869d65ca7..8b3b45f79ff 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging +from psycopg2 import sql, extras from datetime import datetime, timedelta, date from dateutil.relativedelta import relativedelta @@ -9,6 +10,8 @@ from odoo import api, fields, models, tools, SUPERUSER_ID from odoo.tools.translate import _ from odoo.tools import email_re, email_split from odoo.exceptions import UserError, AccessError +from odoo.addons.phone_validation.tools import phone_validation +from collections import OrderedDict from . import crm_stage @@ -50,9 +53,17 @@ class Lead(models.Model): _name = "crm.lead" _description = "Lead/Opportunity" _order = "priority desc,activity_date_deadline,id desc" - _inherit = ['mail.thread.cc', 'mail.thread.blacklist', 'mail.activity.mixin', 'utm.mixin', 'format.address.mixin'] + _inherit = ['mail.thread.cc', 'mail.thread.blacklist', 'mail.activity.mixin', + 'utm.mixin', 'format.address.mixin', 'phone.validation.mixin'] _primary_email = 'email_from' + @api.model_cr_context + def _auto_init(self): + res = super(Lead, self)._auto_init() + tools.create_index(self._cr, 'crm_lead_create_date_team_id_idx', + self._table, ['create_date', 'team_id']) + return res + def _default_stage_id(self): team = self.env['crm.team'].sudo()._get_default_team_id(user_id=self.env.uid) return self._stage_find(team_id=team.id, domain=[('fold', '=', False)]).id @@ -90,8 +101,20 @@ class Lead(models.Model): date_last_stage_update = fields.Datetime(string='Last Stage Update', index=True, default=fields.Datetime.now) date_conversion = fields.Datetime('Conversion Date', readonly=True) - # Only used for type opportunity + # Probability - Only used for type opportunity probability = fields.Float('Probability', group_operator="avg", default=10.0) + automated_probability = fields.Float('Automated Probability', readonly=True) + is_automated_probability = fields.Boolean('Is automated probability?', compute="_compute_is_automated_probability") + phone_state = fields.Selection([ + ('correct', 'Correct'), + ('incorrect', 'Incorrect'), + ('empty', 'Empty')], string='Phone Quality', default="empty", compute="_compute_phone_state", store=True) + email_state = fields.Selection([ + ('correct', 'Correct'), + ('incorrect', 'Incorrect'), + ('empty', 'Empty')], string='Email quality', default="empty", compute="_compute_email_state", store=True) + + # Only used for type opportunity planned_revenue = fields.Monetary('Expected Revenue', currency_field='company_currency', tracking=True) expected_revenue = fields.Monetary('Prorated Revenue', currency_field='company_currency', store=True, compute="_compute_expected_revenue") date_deadline = fields.Date('Expected Closing', help="Estimate of the date on which the opportunity will be won.") @@ -180,6 +203,39 @@ 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. """ + for lead in self: + lead.is_automated_probability = tools.float_compare(lead.probability, lead.automated_probability, 2) == 0 + + @api.depends('phone', 'country_id.code') + def _compute_phone_state(self): + for lead in self: + phone_status = 'empty' + if lead.phone: + country_code = lead.country_id.code if lead.country_id and lead.country_id.code else None + try: + if phone_validation.phone_parse(lead.phone, country_code): # otherwise library not installed + phone_status = 'correct' + except UserError: + phone_status = 'incorrect' + lead.phone_state = phone_status + + @api.depends('email_from') + def _compute_email_state(self): + for lead in self: + email_state = 'empty' + if lead.email_from: + email_state = 'incorrect' + for email in email_split(lead.email_from): + if tools.email_validate(email): + email_state = 'correct' + break + lead.email_state = email_state + @api.multi def _compute_meeting_count(self): meeting_data = self.env['calendar.event'].read_group([('opportunity_id', 'in', self.ids)], ['opportunity_id'], ['opportunity_id']) @@ -238,6 +294,48 @@ class Lead(models.Model): 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') @api.multi def _valid_team(self): @@ -249,11 +347,13 @@ class Lead(models.Model): @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') res = {'domain': {'state_id': []}} if self.country_id: res['domain']['state_id'] = [('country_id', '=', self.country_id.id)] @@ -293,7 +393,10 @@ class Lead(models.Model): onchange_values.update(vals) # we don't want to overwrite any existing key vals = onchange_values - return super(Lead, self.with_context(context)).create(vals) + result = super(Lead, self.with_context(context)).create(vals) + # Compute new probability for each lead separately + result._write_probability(vals) + return result @api.multi def write(self, vals): @@ -313,7 +416,32 @@ class Lead(models.Model): vals['date_closed'] = False if vals.get('user_id') and 'date_open' not in vals: vals['date_open'] = fields.Datetime.now() - return super(Lead, self).write(vals) + + write_result = super(Lead, self).write(vals) + # Compute new automated_probability (and, eventually, probability) for each lead separately + self._write_probability(vals) + + return write_result + + def _write_probability(self, vals): + compute = False + fields_to_check = ['tag_ids', 'stage_id', 'team_id'] + self._pls_get_safe_fields() + for field, value in vals.items(): + if field in fields_to_check: + compute = True + break + + if not compute: + return + lead_probabilities = self._pls_get_naive_bayes_probabilities() + for lead in self: + 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: + proba_vals['probability'] = lead_proba + super(Lead, lead).write(proba_vals) + return @api.multi @api.returns('self', lambda value: value.id) @@ -349,8 +477,9 @@ class Lead(models.Model): @api.multi def action_set_lost(self): - """ Lost semantic: probability = 0, active = False """ - return self.write({'probability': 0, 'active': False}) + """ Lost semantic: probability = 0 or active = False """ + result = self.write({'active': False, 'probability': 0}) + return result @api.multi def action_set_won(self): @@ -358,9 +487,11 @@ class Lead(models.Model): for lead in self: stage_id = lead._stage_find(domain=[('is_won', '=', True)]) lead.write({'stage_id': stage_id.id, 'probability': 100}) - return True + def action_set_automated_probability(self): + self.write({'probability': self.automated_probability}) + @api.multi def action_set_won_rainbowman(self): self.ensure_one() @@ -1218,6 +1349,376 @@ class Lead(models.Model): 'template': '/crm/static/xls/crm_lead.xls' }] + # ---------------------------------------- + # Predictive Lead Scoring + # ---------------------------------------- + def _pls_get_naive_bayes_probabilities(self, batch_mode=False): + """ + In machine learning, naive Bayes classifiers (NBC) are a family of simple "probabilistic classifiers" based on + applying Bayes theorem with strong (naive) independence assumptions between the variables taken into account. + E.g: will TDE eat m&m's depending on his sleep status, the amount of work he has and the fullness of his stomach? + As we use experience to compute the statistics, every day, we will register the variables state + the result. + As the days pass, we will be able to determine, with more and more precision, if TDE will eat m&m's + for a specific combination : + - did sleep very well, a lot of work and stomach full > Will never happen ! + - didn't sleep at all, no work at all and empty stomach > for sure ! + Following Bayes' Theorem: the probability that an event occurs (to win) under certain conditions is proportional + to the probability to win under each condition separately and the probability to win. We compute a 'Win score' + -> P(Won | A∩B) ∝ P(A∩B | Won)*P(Won) OR S(Won | A∩B) = P(A∩B | Won)*P(Won) + To compute a percentage of probability to win, we also compute the 'Lost score' that is proportional to the + probability to lose under each condition separately and the probability to lose. + -> Probability = S(Won | A∩B) / ( S(Won | A∩B) + S(Lost | A∩B) ) + See https://www.youtube.com/watch?v=CPqOCI0ahss can help to get a quick and simple example. + One issue about NBC is when a event occurence is never observed. + E.g: if when TDE has an empty stomach, he always eat m&m's, than the "not eating m&m's when empty stomach' event + will never be observed. + This is called 'zero frequency' and that leads to division (or at least multiplication) by zero. + To avoid this, we add 0.1 in each frequency. With few data, the computation is than not really realistic. + The more we have records to analyse, the more the estimation will be precise. + :return: probability in percent (and integer rounded) that the lead will be won at the current stage. + """ + lead_probabilities = {} + if len(self) == 0: + return lead_probabilities + + LeadScoringFrequency = self.env['crm.lead.scoring.frequency'] + + # get stages + first_stage_id = self.env['crm.stage'].search([], order='sequence', limit=1) + + # Get all leads values, no matter the team_id + leads_values_dict = self._pls_get_lead_pls_values(batch_mode=batch_mode) + if not leads_values_dict: + return lead_probabilities + + # Get unique couples to search in frequency table + leads_values = set() + for values in leads_values_dict.values(): + for couple in values['values']: + leads_values.add(couple) + + # get all variable related records from frequency table, no matter the team_id + fields = list(set([lead_value[0] for lead_value in leads_values])) + frequencies = LeadScoringFrequency.search([('variable', 'in', fields)], order="team_id asc") + + # get all team_ids from frequencies + frequency_teams = frequencies.mapped('team_id') + frequency_team_ids = [0] + [team.id for team in frequency_teams] + + # 1. Compute each variable value count individually + # regroup each variable to be able to compute their own probabilities + # As all the variable does not enter into account (as we reject unset values in the process) + # each value probability must be computed only with their own variable related total count + # special case: for lead for which team_id is not in frequency table, + # we consider all the records, independently from team_id (this is why we add a result[-1]) + result = dict((team_id, dict((field, dict(won_total=0, lost_total=0)) for field in fields)) for team_id in frequency_team_ids) + result[-1] = dict((field, dict(won_total=0, lost_total=0)) for field in fields) + for frequency in frequencies: + team_result = result[frequency.team_id.id if frequency.team_id else 0] + + field = frequency['variable'] + value = frequency['value'] + + team_result[field][value] = {'won': frequency['won_count'], 'lost': frequency['lost_count']} + team_result[field]['won_total'] += frequency['won_count'] + team_result[field]['lost_total'] += frequency['lost_count'] + + if value not in result[-1][field]: + result[-1][field][value] = {'won': 0, 'lost': 0} + result[-1][field][value]['won'] += frequency['won_count'] + result[-1][field][value]['lost'] += frequency['won_count'] + result[-1][field]['won_total'] += frequency['won_count'] + result[-1][field]['lost_total'] += frequency['won_count'] + + # Get all won, lost and total count for all records in frequencies per team_id + for team_id in result: + result[team_id]['team_won'], \ + result[team_id]['team_lost'], \ + result[team_id]['team_total'] = self._pls_get_won_lost_total_count(result[team_id], first_stage_id) + + save_team_id = None + p_won, p_lost = 1, 1 + for lead_id, lead_values in leads_values_dict.items(): + lead_team_id = lead_values['team_id'] if lead_values['team_id'] else 0 # team_id = None -> Convert to 0 + lead_team_id = lead_team_id if lead_team_id in result else -1 # team_id not in frequency Table -> convert to -1 + if lead_team_id != save_team_id: + save_team_id = lead_team_id + team_won = result[save_team_id]['team_won'] + team_lost = result[save_team_id]['team_lost'] + team_total = result[save_team_id]['team_total'] + # if one count = 0, we cannot compute lead probability + if not team_won or not team_lost: + continue + p_won = team_won / team_total + p_lost = team_lost / team_total + + # 2. Compute won and lost score using each variable's individual probability + s_lead_won, s_lead_lost = p_won, p_lost + for field, value in lead_values['values']: + field_result = result.get(save_team_id, {}).get(field) + value_result = field_result.get(str(value)) if field_result else False + if value_result: + total_won = team_won if field == 'stage_id' else field_result['won_total'] + total_lost = team_lost if field == 'stage_id' else field_result['lost_total'] + + s_lead_won *= value_result['won'] / total_won + s_lead_lost *= value_result['lost'] / total_lost + + # 3. Compute Probability to win + lead_probabilities[lead_id] = 100 * s_lead_won / (s_lead_won + s_lead_lost) + return lead_probabilities + + def _cron_update_automated_probabilities(self): + # Clear the frequencies table (in sql to speed up the cron) + try: + self.check_access_rights('unlink') + except AccessError: + raise UserError(_("You don't have the access needed to run this cron.")) + else: + self._cr.execute('TRUNCATE TABLE crm_lead_scoring_frequency') + + # get stages by sequence + stage_ids = self.env['crm.stage'].search_read([], ['sequence', 'name', 'id'], order='sequence') + stage_sequences = {stage['id']: stage['sequence'] for stage in stage_ids} + + values_to_create = [] + # Compute stat individually for each team + for team in self.env['crm.team'].with_context(active_test=False).search([]): + values_to_create = self._pls_update_frequency_table(values_to_create, stage_ids, stage_sequences, team_id=team.id) + values_to_create = self._pls_update_frequency_table(values_to_create, stage_ids, stage_sequences) + + # create all frequencies from all company and team in batch + self.env['crm.lead.scoring.frequency'].create(values_to_create) + _logger.info("Predictive Lead Scoring : crm.lead.scoring.frequency table rebuilt") + + # Recompute all the leads. Won : probability = 100 | Lost : probability = 0 or inactive + # Here, inactive won't be returned anyway + pls_start_date = self._pls_get_safe_start_date() + if pls_start_date: + pending_lead_domain = [('probability', '<', 100), ('probability', '>', 0), ('create_date', '>', pls_start_date)] + leads_to_update = self.env['crm.lead'].search(pending_lead_domain) + lead_probabilities = leads_to_update._pls_get_naive_bayes_probabilities(batch_mode=True) + + # Update in execute batch to avoid server roundtrips + page_size to 10000 to avoid memory errors + sql = "update crm_lead set automated_probability = %s where id = %s" + batch_params = [(lead_probabilities[lead.id], lead.id) for lead in leads_to_update if lead.id in lead_probabilities] + extras.execute_batch(self._cr, sql, batch_params, page_size=10000) + _logger.info("Predictive Lead Scoring : all automated probability updated (count: %d)" % (len(leads_to_update))) + + # ---------------------------- + # Utility Tools for PLS + # ---------------------------- + + # PLS Config Parameters + # --------------------- + def _pls_get_safe_start_date(self): + """ As config_parameters does not accept Date field, + we get directly the date formated string stored into the Char config field, + as we directly use this string in the sql queries. + To avoid sql injections when using this config param, + we ensure the date string can be effectively a date.""" + str_date = self.env['ir.config_parameter'].sudo().get_param('crm.pls_start_date') + if not fields.Date.to_date(str_date): + return False + return str_date + + def _pls_get_safe_fields(self): + """ As config_parameters does not accept M2M field, + we the fields from the formated string stored into the Char config field. + To avoid sql injections when using that list, we return only the fields + that are defined on the model. """ + pls_fields_config = self.env['ir.config_parameter'].sudo().get_param('crm.pls_fields') + pls_fields = pls_fields_config.split(',') if pls_fields_config else [] + pls_safe_fields = [field for field in pls_fields if field in self._fields] + return pls_safe_fields + + # Rebuild Frequency Table Tools + # ----------------------------- + def _pls_update_frequency_table(self, values_to_create, stage_ids, stage_sequences, team_id=None): + """ Create / update the frequency table in a cross company way, per team_id""" + pls_start_date = self._pls_get_safe_start_date() + if not pls_start_date: + return values_to_create + + fields = ['stage_id', 'team_id'] + self._pls_get_safe_fields() + frequencies = dict((field, {}) for field in (fields + ['tag_id'])) + + frequencies = self._pls_update_frequency_table_fields(frequencies, stage_ids, stage_sequences, fields, team_id, pls_start_date) + frequencies = self._pls_update_frequency_table_tag(frequencies, team_id, pls_start_date) + + # build the create multi + for field, value in frequencies.items(): + for param, result in value.items(): + # To avoid that a tag take to much importance if his subset is too small, + # we include the tag frequencies in the frequency table only if at least 50 won or lost leads had this tag. + if field != 'tag_id' or (result['won'] + result['lost']) >= 50: + # We add + 0.1 in won and lost counts to avoid zero frequency issues + # should be +1 but it weights too much on small recordset. + values_to_create.append({ + 'variable': field, + 'value': param, + 'won_count': result['won'] + 0.1, + 'lost_count': result['lost'] + 0.1, + 'team_id': team_id + }) + return values_to_create + + def _pls_update_frequency_table_fields(self, frequencies, stage_ids, stage_sequences, fields, team_id, pls_start_date): + # get all lead fields combination aggregated by won / lost count + # Prepare fields injection + team_condition = 'and l.team_id = %s' if team_id else 'and l.team_id is null' + str_fields = ", ".join(["{}"] * len(fields)) + args = [sql.Identifier(field) for field in fields] * 2 + + # Build sql query in safe mode + query = """select probability, active, %s, count(probability) as count + from crm_lead l + where (probability = 0 or probability >= 100) + and create_date > %%s + %s + group by probability, active, %s """ + query = sql.SQL(query % (str_fields, team_condition, str_fields)).format(*args) + + query_params = [pls_start_date] + ([int(team_id)] if team_id else []) + self._cr.execute(query, query_params) + results = self._cr.dictfetchall() + + # Increment won / lost frequencies by criteria (field / value couple) + for result in results: + won = result['count'] if result['probability'] == 100 else 0 + lost = result['count'] if result['probability'] == 0 else 0 + for field in fields: + value = result[field] + if value: + if field == 'stage_id': + if won: # increment all stages if won + stages_to_increment = [stage['id'] for stage in stage_ids] + else: # increment only current + previous stages if lost + current_stage_sequence = stage_sequences[value] + stages_to_increment = [stage['id'] for stage in stage_ids if + stage['sequence'] <= current_stage_sequence] + for stage in stages_to_increment: + frequencies = self._pls_increment_frequency(frequencies, field, stage, won, lost) + else: + frequencies = self._pls_increment_frequency(frequencies, field, value, won, lost) + + return frequencies + + def _pls_update_frequency_table_tag(self, frequencies, team_id, pls_start_date): + # get all tag_ids won / lost count + query = """select l.probability, l.active, t.id, count(l.probability) as count + from crm_lead_tag_rel rel + inner join crm_lead_tag t on rel.tag_id = t.id + inner join crm_lead l on l.id = rel.lead_id + where (l.probability = 0 or l.probability >= 100) + and l.create_date > %%s + %s + group by l.probability, l.active, t.id""" + team_condition = 'and l.team_id = %s' if team_id else 'and l.team_id is null' + query_params = [pls_start_date] + ([int(team_id)] if team_id else []) + self._cr.execute(query % team_condition, query_params) + tag_results = self._cr.dictfetchall() + + for result in tag_results: + won = result['count'] if result['probability'] == 100 else 0 + lost = result['count'] if result['probability'] == 0 else 0 + value = result['id'] + frequencies = self._pls_increment_frequency(frequencies, 'tag_id', value, won, lost) + + return frequencies + + def _pls_increment_frequency(self, frequencies, field, value, won, lost): + if value not in frequencies[field]: + frequencies[field][value] = {'won': won, 'lost': lost} + else: + frequencies[field][value]['won'] += won + frequencies[field][value]['lost'] += lost + return frequencies + + # Compute Automated Probability Tools + # ----------------------------------- + def _pls_get_lead_pls_values(self, batch_mode=False): + """ + Due to onchange, we don't have always the id of the lead to recompute. + When we update few records (one, typically) with onchanges, we build the + lead_values (= couple field/value) using the ORM. + To speed up the computation and avoid making too much DB read inside loops, + we can activate the batch_mode, that needs the id of each lead to recompute to be known. + That batch mode is directly making sql queries to bypass the ORM, + so we can get everything we need for all leads to recompute in a minimum number of queries. + This batch mode is currently called when the computation is triggered by + crm.lead._cron_update_automated_probabilities(). + :param team_id: (int) team_id to search on + :param batch_mode: (bool) batch mode + :return: dict of list of tuple of field - value by lead ({lead_id: [(field1: value1), (field2: value2), ...], ...}) + """ + leads_values_dict = OrderedDict() + fields = ["stage_id", "team_id"] + self._pls_get_safe_fields() + if batch_mode: + # get all info on leads + # Prepare fields injection + str_fields = ", ".join(["{}"] * len(fields)) + args = [sql.Identifier(field) for field in fields] + # Build sql query in safe mode + query = """SELECT id, %s + FROM crm_lead l + WHERE probability > 0 AND probability < 100 AND active = True AND id in %%s order by team_id asc""" + query = sql.SQL(query % str_fields).format(*args) + + self._cr.execute(query, [tuple(self.ids)]) + lead_results = self._cr.dictfetchall() + + query = """SELECT l.id as lead_id, t.id as tag_id + FROM crm_lead l + LEFT JOIN crm_lead_tag_rel rel ON l.id = rel.lead_id + LEFT JOIN crm_lead_tag t ON rel.tag_id = t.id + WHERE l.probability > 0 AND l.probability < 100 AND l.active = True AND l.id in %s order by l.team_id asc""" + self._cr.execute(query, [tuple(self.ids)]) + tag_results = self._cr.dictfetchall() + + # get all (variable, value) couple for all in self + for lead in lead_results: + lead_values = [] + for field in fields: + if field == 'team_id': # ignore team_id as stored separately in leads_values_dict[lead_id][team_id] + continue + value = lead[field] + if value: + lead_values.append((field, value)) + leads_values_dict[lead['id']] = {'values': lead_values, 'team_id': lead['team_id']} + + for tag in tag_results: + if tag['tag_id']: + leads_values_dict[tag['lead_id']]['values'].append(('tag_id', tag['tag_id'])) + return leads_values_dict + else: + for lead in self: + lead_values = [] + for field in fields: + if field == 'team_id': # ignore team_id as stored separately in leads_values_dict[lead_id][team_id] + continue + value = lead[field].id if isinstance(lead[field], models.BaseModel) else lead[field] + if value: + lead_values.append((field, value)) + for tag in lead.tag_ids: + lead_values.append(('tag_id', tag.id)) + leads_values_dict[lead.id] = {'values': lead_values, 'team_id': lead['team_id'].id} + return leads_values_dict + + def _pls_get_won_lost_total_count(self, team_results, first_stage_id): + """ Get all won and all lost + total : + first stage can be used to know how many lost and won there is + as won count are equals for all stage + and first stage is always incremented in lost_count + :param frequencies: lead_scoring_frequencies + :param first_stage_id: stage with smallest sequence + :return: won count, lost count and total count for all records in frequencies + """ + if str(first_stage_id.id) not in team_results['stage_id']: + return 0, 0, 0 + stage_result = team_results['stage_id'][str(first_stage_id.id)] + return stage_result['won'], stage_result['lost'], stage_result['won'] + stage_result['lost'] + class Tag(models.Model): diff --git a/addons/crm/models/crm_lead_scoring_frequency.py b/addons/crm/models/crm_lead_scoring_frequency.py new file mode 100644 index 00000000000..fc6d0bdbbbe --- /dev/null +++ b/addons/crm/models/crm_lead_scoring_frequency.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models + + +class LeadScoringFrequency(models.Model): + _name = 'crm.lead.scoring.frequency' + _description = 'Lead Scoring Frequency' + + variable = fields.Char('Variable', index=True) + value = fields.Char('Value') + won_count = fields.Float('Won Count', digits=(16, 1)) # Float because we add 0.1 to avoid zero Frequency issue + lost_count = fields.Float('Lost Count', digits=(16, 1)) # Float because we add 0.1 to avoid zero Frequency issue + team_id = fields.Many2one('crm.team', 'Sales Team') + + +class FrequencyField(models.Model): + _name = 'crm.lead.scoring.frequency.field' + _description = 'Fields that can be used for predictive lead scoring computation' + + name = fields.Char(related="field_id.field_description") + field_id = fields.Many2one('ir.model.fields', domain=[('model_id.model', '=', 'crm.lead')], required=True) diff --git a/addons/crm/models/res_config_settings.py b/addons/crm/models/res_config_settings.py index d364d1815fe..83ae62ff50e 100644 --- a/addons/crm/models/res_config_settings.py +++ b/addons/crm/models/res_config_settings.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models +from datetime import datetime, date class ResConfigSettings(models.TransientModel): @@ -15,6 +16,10 @@ class ResConfigSettings(models.TransientModel): module_crm_iap_lead_website = fields.Boolean("Create Leads/Opportunities from your website's traffic") lead_mining_in_pipeline = fields.Boolean("Create a lead mining request directly from the opportunity pipeline.", config_parameter='crm.lead_mining_in_pipeline') crm_phone_valid_method = fields.Selection(related="company_id.phone_international_format", required=True, readonly=False) + predictive_lead_scoring_start_date = fields.Date(string='Lead Scoring Starting Date', compute="_compute_pls_start_date", inverse="_inverse_pls_start_date_str") + predictive_lead_scoring_start_date_str = fields.Char(string='Lead Scoring Starting Date in String', default=date.today().strftime('%Y-%m-%d'), config_parameter='crm.pls_start_date') + 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') def _find_default_lead_alias_id(self): alias = self.env.ref('crm.mail_alias_lead_info', False) @@ -28,6 +33,38 @@ class ResConfigSettings(models.TransientModel): ], limit=1) return alias + @api.depends('predictive_lead_scoring_fields_str') + def _compute_pls_fields(self): + """ As config_parameters does not accept m2m field, + we get the fields back from the Char config field, to ease the configuration in config panel """ + for setting in self: + if setting.predictive_lead_scoring_fields_str: + names = setting.predictive_lead_scoring_fields_str.split(',') + setting.predictive_lead_scoring_fields = self.env['crm.lead.scoring.frequency.field'].search([('name', 'in', names)]) + else: + setting.predictive_lead_scoring_fields = None + + def _inverse_pls_fields_str(self): + """ As config_parameters does not accept m2m field, + we store the fields with a comma separated string into a Char config field """ + for setting in self: + if setting.predictive_lead_scoring_fields: + setting.predictive_lead_scoring_fields_str = ','.join(setting.predictive_lead_scoring_fields.mapped('name')) + + @api.depends('predictive_lead_scoring_start_date_str') + def _compute_pls_start_date(self): + """ As config_parameters does not accept Date field, + we get the date back from the Char config field, to ease the configuration in config panel """ + for setting in self: + setting.predictive_lead_scoring_start_date = fields.Date.to_date(setting.predictive_lead_scoring_start_date_str) + + def _inverse_pls_start_date_str(self): + """ As config_parameters does not accept Date field, + we store the date formated string into a Char config field """ + for setting in self: + 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): """ Reset alias / leads configuration if leads are not used """ diff --git a/addons/crm/security/ir.model.access.csv b/addons/crm/security/ir.model.access.csv index a5da8f8fa02..7269054d25b 100644 --- a/addons/crm/security/ir.model.access.csv +++ b/addons/crm/security/ir.model.access.csv @@ -21,3 +21,5 @@ access_calendar_event_type_sale_manager,calendar.event.type.manager,calendar.mod access_calendar_event_type_sale_user,calendar.event.type.user,calendar.model_calendar_event_type,base.group_user,1,0,0,0 access_calendar_event_type_sale_salesman,calendar.event.type.salesman,calendar.model_calendar_event_type,sales_team.group_sale_salesman,1,0,0,0 access_mail_activity_type_sale_manager,mail.activity.type.sale.manager,mail.model_mail_activity_type,sales_team.group_sale_manager,1,1,1,1 +access_crm_lead_scoring_frequency,access_crm_lead_scoring_frequency,model_crm_lead_scoring_frequency,sales_team.group_sale_salesman,1,0,0,0 +access_crm_lead_scoring_frequency_field,access_crm_lead_scoring_frequency_field,model_crm_lead_scoring_frequency_field,sales_team.group_sale_salesman,1,0,0,0 diff --git a/addons/crm/static/src/css/crm.css b/addons/crm/static/src/css/crm.css index f9cbe46b289..0cd13b24b70 100644 --- a/addons/crm/static/src/css/crm.css +++ b/addons/crm/static/src/css/crm.css @@ -2,3 +2,11 @@ .openerp .oe_margin_top_8 { margin-top: 8px; } + +.o_pls_probability button { + font-size: 1.2rem; +} + +.o_pls_probability small { + font-size: 1rem; +} diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 65372748bcf..0384fe3b40e 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -74,6 +74,23 @@
@@ -101,6 +118,8 @@ + +