Spaces:
Running
Running
File size: 7,900 Bytes
8445ed4 aece405 8445ed4 aece405 8445ed4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | """Cálculo de métricas de Leads.
Todos os indicadores são calculados em Python a partir de DataFrames pandas.
A função principal é :func:`calculate_lead_metrics`.
"""
from __future__ import annotations
from typing import Any
import pandas as pd
from .comparison_metrics import aplicar_comparacoes
from ..utils.validators import arredondar, is_dataframe_vazio, percentual
# Chaves numéricas comparáveis contra histórico.
_CHAVES_COMPARAVEIS = [
"new_leads",
"converted_leads",
"conversion_rate",
"leads_without_first_task",
"avg_time_to_first_task_hours",
"median_time_to_first_task_hours",
]
def _tem_primeira_tarefa(valor: Any) -> bool:
"""Indica se o campo de primeira tarefa está preenchido.
Aceita datetime, string não vazia ou booleano verdadeiro.
"""
if valor is None:
return False
if isinstance(valor, bool):
return valor
if isinstance(valor, float) and valor != valor: # NaN
return False
texto = str(valor).strip().lower()
return texto not in {"", "nan", "nat", "none", "false", "0"}
def _horas_ate_primeira_tarefa(
df: pd.DataFrame, first_task_field: str
) -> list[float]:
"""Calcula, em horas, o tempo entre criação do lead e a primeira tarefa.
Considera apenas linhas em que o campo de primeira tarefa é uma data válida.
"""
if is_dataframe_vazio(df) or first_task_field not in df.columns:
return []
if "CreatedDate" not in df.columns:
return []
criado = pd.to_datetime(df["CreatedDate"], errors="coerce", utc=True)
primeira = pd.to_datetime(df[first_task_field], errors="coerce", utc=True)
delta = (primeira - criado).dt.total_seconds() / 3600.0
# Mantém apenas valores válidos e não negativos.
validos = delta[(delta.notna()) & (delta >= 0)]
return [float(h) for h in validos.tolist()]
def _nome_owner(row: Any) -> str:
"""Extrai o nome do dono (Owner.Name) de uma linha de Lead."""
owner = row.get("Owner")
if isinstance(owner, dict):
nome = str(owner.get("Name") or "").strip()
if nome:
return nome
oid = row.get("OwnerId")
return str(oid).strip() if oid is not None and str(oid).strip() else "—"
def _leads_por_owner(df: pd.DataFrame) -> dict[str, dict[str, Any]]:
"""Distribuição de leads por vendedor (dono), com convertidos e taxa.
Usa os leads modificados (que carregam ``IsConverted`` e o dono).
"""
if is_dataframe_vazio(df):
return {}
if "Owner" not in df.columns and "OwnerId" not in df.columns:
return {}
work = df.copy()
work["_owner"] = work.apply(_nome_owner, axis=1)
if "IsConverted" in work.columns:
work["_conv"] = work["IsConverted"].fillna(False).astype(bool)
else:
work["_conv"] = False
resultado: dict[str, dict[str, Any]] = {}
for owner, grupo in work.groupby("_owner"):
total = int(len(grupo))
convertidos = int(grupo["_conv"].sum())
resultado[str(owner)] = {
"total": total,
"converted": convertidos,
"not_converted": total - convertidos,
"conversion_rate": percentual(convertidos, total) if total else 0.0,
}
# Ordena por volume (desc) e limita a 20 vendedores.
ordenado = sorted(resultado.items(), key=lambda kv: kv[1]["total"], reverse=True)
return dict(ordenado[:20])
def _conversao_por_origem(df_modificados: pd.DataFrame) -> dict[str, float]:
"""Calcula a taxa de conversão (%) por LeadSource nos leads modificados."""
if is_dataframe_vazio(df_modificados):
return {}
if "LeadSource" not in df_modificados.columns or "IsConverted" not in df_modificados.columns:
return {}
df = df_modificados.copy()
df["LeadSource"] = df["LeadSource"].fillna("Sem origem")
df["IsConverted"] = df["IsConverted"].fillna(False).astype(bool)
resultado: dict[str, float] = {}
for origem, grupo in df.groupby("LeadSource"):
total = len(grupo)
convertidos = int(grupo["IsConverted"].sum())
if total > 0:
resultado[str(origem)] = percentual(convertidos, total)
return resultado
def calculate_lead_metrics(
leads_created_df: pd.DataFrame,
leads_modified_df: pd.DataFrame,
previous_metrics: dict[str, Any] | None = None,
seven_day_average: dict[str, float] | None = None,
first_task_field: str = "FirstTask__c",
) -> dict[str, Any]:
"""Calcula as métricas diárias de Leads.
Args:
leads_created_df: Leads criados no dia.
leads_modified_df: Leads modificados no dia (usado para conversão).
previous_metrics: Métricas de Leads do dia anterior (para variação).
seven_day_average: Média de 7 dias por métrica (para variação).
first_task_field: Nome do campo customizado de primeira tarefa.
Returns:
Dicionário de métricas de Leads, incluindo comparações.
"""
# --- Volume e conversão ---
new_leads = 0 if is_dataframe_vazio(leads_created_df) else int(len(leads_created_df))
converted_leads = 0
if not is_dataframe_vazio(leads_modified_df) and "IsConverted" in leads_modified_df.columns:
converted_leads = int(
leads_modified_df["IsConverted"].fillna(False).astype(bool).sum()
)
conversion_rate = percentual(converted_leads, new_leads) if new_leads else 0.0
# --- Leads sem primeira tarefa (entre os criados) ---
leads_without_first_task = 0
if not is_dataframe_vazio(leads_created_df) and first_task_field in leads_created_df.columns:
sem_tarefa = ~leads_created_df[first_task_field].apply(_tem_primeira_tarefa)
leads_without_first_task = int(sem_tarefa.sum())
elif new_leads:
# Campo não disponível: assume que todos estão sem tarefa registrada.
leads_without_first_task = new_leads
# --- Tempo até a primeira tarefa ---
horas = _horas_ate_primeira_tarefa(leads_created_df, first_task_field)
serie_horas = pd.Series(horas, dtype="float64")
avg_time = arredondar(float(serie_horas.mean()), 2) if horas else None
median_time = arredondar(float(serie_horas.median()), 2) if horas else None
# --- Origens ---
top_source = None
if not is_dataframe_vazio(leads_created_df) and "LeadSource" in leads_created_df.columns:
contagem = leads_created_df["LeadSource"].fillna("Sem origem").value_counts()
if not contagem.empty:
top_source = str(contagem.index[0])
conversao_origem = _conversao_por_origem(leads_modified_df)
best_source = None
worst_source = None
if conversao_origem:
best_source = max(conversao_origem, key=conversao_origem.get)
worst_source = min(conversao_origem, key=conversao_origem.get)
# --- Montagem ---
metricas: dict[str, Any] = {
"new_leads": new_leads,
"converted_leads": converted_leads,
"conversion_rate": conversion_rate,
"leads_without_first_task": leads_without_first_task,
"avg_time_to_first_task_hours": avg_time,
"median_time_to_first_task_hours": median_time,
"top_lead_source_by_volume": top_source,
"best_lead_source_by_conversion": best_source,
"worst_lead_source_by_conversion": worst_source,
"conversion_rate_by_source": conversao_origem,
"leads_by_owner": _leads_por_owner(leads_modified_df),
}
# --- Comparações históricas ---
comparacoes = aplicar_comparacoes(
metricas, previous_metrics, seven_day_average, _CHAVES_COMPARAVEIS
)
metricas["variation_vs_previous_day"] = {
k: v["variation_vs_previous"] for k, v in comparacoes.items()
}
metricas["variation_vs_7day_average"] = {
k: v["variation_vs_7day_avg"] for k, v in comparacoes.items()
}
metricas["comparisons"] = comparacoes
return metricas
|