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