"""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