Spaces:
Sleeping
Sleeping
Commit ·
463cf95
1
Parent(s): bbc30f3
Contratos/reajuste, storytelling, e-mail responsivo/dark mode, dashboard
Browse files- .env.example +20 -0
- frontend-react/src/components/HighlightsCards.jsx +8 -0
- frontend-react/src/style.css +19 -0
- frontend-react/src/views/Dashboard.jsx +34 -0
- src/agent/analytical_force_agent.py +45 -0
- src/agent/prompt_builder.py +16 -5
- src/analytics/__init__.py +2 -0
- src/analytics/contract_metrics.py +193 -0
- src/config/settings.py +24 -3
- src/delivery/email_sender.py +168 -17
- src/models/template_client.py +120 -34
- src/salesforce/extractors.py +123 -0
- src/salesforce/queries.py +28 -0
.env.example
CHANGED
|
@@ -188,3 +188,23 @@ SF_CONTRACT_ACCOUNT_FIELD=
|
|
| 188 |
# "Contrato__c"). Deixe em branco se não souber: a tela de detalhe do
|
| 189 |
# contrato não lista os itens vinculados até você preencher.
|
| 190 |
SF_CONTRACT_ITEM_PARENT_FIELD=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
# "Contrato__c"). Deixe em branco se não souber: a tela de detalhe do
|
| 189 |
# contrato não lista os itens vinculados até você preencher.
|
| 190 |
SF_CONTRACT_ITEM_PARENT_FIELD=
|
| 191 |
+
# Campo de relacionamento para exibir o NOME da conta nas listagens de
|
| 192 |
+
# contrato (ex.: "Conta__r.Name"). Deixe em branco se não souber.
|
| 193 |
+
SF_CONTRACT_ACCOUNT_NAME_FIELD=
|
| 194 |
+
|
| 195 |
+
# ---- Fonte de Reajuste de Contrato (módulo configurável) ----
|
| 196 |
+
# Alimenta a análise diária de reajuste no relatório/e-mail: quanto foi
|
| 197 |
+
# reajustado no MÊS, comparando o valor atual com o valor anterior do
|
| 198 |
+
# contrato. Deixe SF_CONTRACT_VALUE_FIELD ou SF_CONTRACT_PREVIOUS_VALUE_FIELD
|
| 199 |
+
# em branco para manter este módulo DESATIVADO (nunca inventamos nomes de
|
| 200 |
+
# campo — regra 3/16/17 do projeto).
|
| 201 |
+
# Campo com o valor ATUAL do contrato.
|
| 202 |
+
SF_CONTRACT_VALUE_FIELD=
|
| 203 |
+
# Campo com o valor ANTERIOR ao reajuste.
|
| 204 |
+
SF_CONTRACT_PREVIOUS_VALUE_FIELD=
|
| 205 |
+
# Campo com o percentual/valor de reajuste já informado no contrato
|
| 206 |
+
# (opcional). Se vazio, o reajuste é ESTIMADO como (valor atual - anterior).
|
| 207 |
+
SF_CONTRACT_READJUSTMENT_FIELD=
|
| 208 |
+
# Campo de DATA em que o reajuste foi aplicado (usado para filtrar os
|
| 209 |
+
# reajustes do mês corrente). Obrigatório para o módulo funcionar.
|
| 210 |
+
SF_CONTRACT_READJUSTMENT_DATE_FIELD=
|
frontend-react/src/components/HighlightsCards.jsx
CHANGED
|
@@ -3,18 +3,26 @@ import SecaoHeader from "./SecaoHeader.jsx";
|
|
| 3 |
const COR = {
|
| 4 |
leads_criados: "#16a34a",
|
| 5 |
leads_sem_tarefa: "#f59e0b",
|
|
|
|
| 6 |
oportunidades_travadas: "#e11d48",
|
| 7 |
oportunidades_ganhas: "#2563eb",
|
|
|
|
| 8 |
cancelamentos: "#be123c",
|
|
|
|
| 9 |
satisfacoes_piores: "#0d9488",
|
|
|
|
| 10 |
};
|
| 11 |
const ROTULO = {
|
| 12 |
leads_criados: "🆕 Leads criados",
|
| 13 |
leads_sem_tarefa: "⏳ Leads sem 1ª tarefa",
|
|
|
|
| 14 |
oportunidades_travadas: "🚧 Oportunidades travadas",
|
| 15 |
oportunidades_ganhas: "🏆 Oportunidades ganhas",
|
|
|
|
| 16 |
cancelamentos: "❌ Cancelamentos",
|
|
|
|
| 17 |
satisfacoes_piores: "😟 Piores satisfações",
|
|
|
|
| 18 |
};
|
| 19 |
|
| 20 |
// Renderiza os "Registros do dia" — links diretos para os registros no
|
|
|
|
| 3 |
const COR = {
|
| 4 |
leads_criados: "#16a34a",
|
| 5 |
leads_sem_tarefa: "#f59e0b",
|
| 6 |
+
oportunidades_criadas: "#0018FF",
|
| 7 |
oportunidades_travadas: "#e11d48",
|
| 8 |
oportunidades_ganhas: "#2563eb",
|
| 9 |
+
oportunidades_perdidas: "#e11d48",
|
| 10 |
cancelamentos: "#be123c",
|
| 11 |
+
satisfacoes_do_dia: "#16C79A",
|
| 12 |
satisfacoes_piores: "#0d9488",
|
| 13 |
+
contratos_modificados: "#0004DD",
|
| 14 |
};
|
| 15 |
const ROTULO = {
|
| 16 |
leads_criados: "🆕 Leads criados",
|
| 17 |
leads_sem_tarefa: "⏳ Leads sem 1ª tarefa",
|
| 18 |
+
oportunidades_criadas: "✨ Oportunidades criadas",
|
| 19 |
oportunidades_travadas: "🚧 Oportunidades travadas",
|
| 20 |
oportunidades_ganhas: "🏆 Oportunidades ganhas",
|
| 21 |
+
oportunidades_perdidas: "📉 Oportunidades perdidas",
|
| 22 |
cancelamentos: "❌ Cancelamentos",
|
| 23 |
+
satisfacoes_do_dia: "😊 Satisfações do dia",
|
| 24 |
satisfacoes_piores: "😟 Piores satisfações",
|
| 25 |
+
contratos_modificados: "📄 Contratos modificados",
|
| 26 |
};
|
| 27 |
|
| 28 |
// Renderiza os "Registros do dia" — links diretos para os registros no
|
frontend-react/src/style.css
CHANGED
|
@@ -145,3 +145,22 @@ input:focus{outline:none;border-color:var(--c1);box-shadow:0 0 0 3px rgba(99,102
|
|
| 145 |
.ccadd{display:flex;gap:8px;margin-top:14px}
|
| 146 |
@media(max-width:860px){.sidebar{transform:translateX(-100%)} .sidebar.open{transform:none} .mainwrap{margin-left:0} .hamb{display:block} .split{grid-template-columns:1fr}}
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
.ccadd{display:flex;gap:8px;margin-top:14px}
|
| 146 |
@media(max-width:860px){.sidebar{transform:translateX(-100%)} .sidebar.open{transform:none} .mainwrap{margin-left:0} .hamb{display:block} .split{grid-template-columns:1fr}}
|
| 147 |
|
| 148 |
+
/* ---- Reforço visual: mais dinâmico/animado (reajuste de contrato, KPIs) ---- */
|
| 149 |
+
.disclaimer{margin-top:12px;padding:10px 13px;border-radius:10px;background:var(--surface2);border:1px dashed var(--line);color:var(--muted);font-size:12px;line-height:1.5}
|
| 150 |
+
.af-glow{position:relative;overflow:hidden}
|
| 151 |
+
.af-glow::before{content:"";position:absolute;inset:-2px;border-radius:var(--radius);padding:2px;
|
| 152 |
+
background:linear-gradient(120deg,var(--c1),var(--c3),var(--c2));background-size:220% 220%;
|
| 153 |
+
-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude;
|
| 154 |
+
animation:af-borderflow 5s ease infinite;opacity:.55;pointer-events:none}
|
| 155 |
+
@keyframes af-borderflow{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
|
| 156 |
+
.kpis .kpi{animation:fadeUp .45s ease both}
|
| 157 |
+
.kpis .kpi:nth-child(1){animation-delay:.02s} .kpis .kpi:nth-child(2){animation-delay:.06s}
|
| 158 |
+
.kpis .kpi:nth-child(3){animation-delay:.10s} .kpis .kpi:nth-child(4){animation-delay:.14s}
|
| 159 |
+
.kpis .kpi:nth-child(5){animation-delay:.18s} .kpis .kpi:nth-child(6){animation-delay:.22s}
|
| 160 |
+
.kpis .kpi:nth-child(7){animation-delay:.26s} .kpis .kpi:nth-child(8){animation-delay:.30s}
|
| 161 |
+
.kpis .kpi:nth-child(9){animation-delay:.34s} .kpis .kpi:nth-child(10){animation-delay:.38s}
|
| 162 |
+
.kpi:hover .ic{transform:scale(1.08) rotate(-4deg);transition:.2s}
|
| 163 |
+
.dcard{transition:transform .2s ease,box-shadow .2s ease;animation:fadeUp .4s ease both}
|
| 164 |
+
.dcard:hover{transform:translateY(-4px) scale(1.01);box-shadow:0 14px 30px rgba(0,4,221,.14)}
|
| 165 |
+
.dgrid .dcard:nth-child(1){animation-delay:.03s} .dgrid .dcard:nth-child(2){animation-delay:.08s}
|
| 166 |
+
.dgrid .dcard:nth-child(3){animation-delay:.13s} .dgrid .dcard:nth-child(4){animation-delay:.18s}
|
frontend-react/src/views/Dashboard.jsx
CHANGED
|
@@ -2,6 +2,7 @@ import SecaoHeader from "../components/SecaoHeader.jsx";
|
|
| 2 |
import StatGrid from "../components/StatGrid.jsx";
|
| 3 |
import AlertList from "../components/AlertList.jsx";
|
| 4 |
import DoughnutChart from "../components/DoughnutChart.jsx";
|
|
|
|
| 5 |
import { moeda, num, pct, pipelineVal, extrairResumo, renderResumoHtml } from "../format.js";
|
| 6 |
|
| 7 |
const ACC = "#6366f1";
|
|
@@ -36,12 +37,14 @@ export default function Dashboard({ dia, onVerAlertas }) {
|
|
| 36 |
const T = dia.metrics?.tasks || {};
|
| 37 |
const Sa = dia.metrics?.satisfaction || {};
|
| 38 |
const C = dia.metrics?.cancellations || {};
|
|
|
|
| 39 |
const resumo = extrairResumo(dia.report_markdown);
|
| 40 |
|
| 41 |
const cards = [
|
| 42 |
["🌱", "Leads novos", num(L.new_leads), "green"],
|
| 43 |
["🎯", "Conversão", pct(L.conversion_rate), "blue"],
|
| 44 |
["💰", "Pipeline aberto", moeda(pipelineVal(O)), "violet"],
|
|
|
|
| 45 |
["🏆", "Ganhas", num(O.won_opportunities), "blue"],
|
| 46 |
["📉", "Perdidas", num(O.lost_opportunities), "red"],
|
| 47 |
["🚧", "Paradas", num(O.stalled_opportunities), "amber"],
|
|
@@ -49,6 +52,10 @@ export default function Dashboard({ dia, onVerAlertas }) {
|
|
| 49 |
];
|
| 50 |
if (Sa.configured && Sa.responses) cards.push(["😊", "Satisfação", num(Sa.avg_score), "teal"]);
|
| 51 |
if (C.configured && C.cancellations_count) cards.push(["❌", "Cancelamentos", num(C.cancellations_count), "red"]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
return (
|
| 54 |
<>
|
|
@@ -62,6 +69,28 @@ export default function Dashboard({ dia, onVerAlertas }) {
|
|
| 62 |
)}
|
| 63 |
<StatGrid itens={cards} />
|
| 64 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
<div className="card">
|
| 66 |
<SecaoHeader
|
| 67 |
icone="🚨"
|
|
@@ -82,6 +111,11 @@ export default function Dashboard({ dia, onVerAlertas }) {
|
|
| 82 |
</div>
|
| 83 |
</div>
|
| 84 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
</>
|
| 86 |
);
|
| 87 |
}
|
|
|
|
| 2 |
import StatGrid from "../components/StatGrid.jsx";
|
| 3 |
import AlertList from "../components/AlertList.jsx";
|
| 4 |
import DoughnutChart from "../components/DoughnutChart.jsx";
|
| 5 |
+
import HighlightsCards from "../components/HighlightsCards.jsx";
|
| 6 |
import { moeda, num, pct, pipelineVal, extrairResumo, renderResumoHtml } from "../format.js";
|
| 7 |
|
| 8 |
const ACC = "#6366f1";
|
|
|
|
| 37 |
const T = dia.metrics?.tasks || {};
|
| 38 |
const Sa = dia.metrics?.satisfaction || {};
|
| 39 |
const C = dia.metrics?.cancellations || {};
|
| 40 |
+
const Co = dia.metrics?.contracts || {};
|
| 41 |
const resumo = extrairResumo(dia.report_markdown);
|
| 42 |
|
| 43 |
const cards = [
|
| 44 |
["🌱", "Leads novos", num(L.new_leads), "green"],
|
| 45 |
["🎯", "Conversão", pct(L.conversion_rate), "blue"],
|
| 46 |
["💰", "Pipeline aberto", moeda(pipelineVal(O)), "violet"],
|
| 47 |
+
["✨", "Oportunidades criadas", num(O.new_opportunities), "blue"],
|
| 48 |
["🏆", "Ganhas", num(O.won_opportunities), "blue"],
|
| 49 |
["📉", "Perdidas", num(O.lost_opportunities), "red"],
|
| 50 |
["🚧", "Paradas", num(O.stalled_opportunities), "amber"],
|
|
|
|
| 52 |
];
|
| 53 |
if (Sa.configured && Sa.responses) cards.push(["😊", "Satisfação", num(Sa.avg_score), "teal"]);
|
| 54 |
if (C.configured && C.cancellations_count) cards.push(["❌", "Cancelamentos", num(C.cancellations_count), "red"]);
|
| 55 |
+
if (Co.configured) cards.push(["📄", "Contratos modificados", num(Co.modified_today_count), "violet"]);
|
| 56 |
+
if (Co.readjustment_configured && Co.readjustment_month_count) {
|
| 57 |
+
cards.push(["💹", "Reajuste no mês", moeda(Co.readjustment_month_total), "teal"]);
|
| 58 |
+
}
|
| 59 |
|
| 60 |
return (
|
| 61 |
<>
|
|
|
|
| 69 |
)}
|
| 70 |
<StatGrid itens={cards} />
|
| 71 |
</div>
|
| 72 |
+
|
| 73 |
+
{Co.readjustment_configured && Co.readjustment_month_count > 0 && (
|
| 74 |
+
<div className="card af-glow">
|
| 75 |
+
<SecaoHeader
|
| 76 |
+
icone="💹"
|
| 77 |
+
titulo="Reajuste de contratos no mês"
|
| 78 |
+
cor="#0d9488"
|
| 79 |
+
extra={<span className="chip">{num(Co.readjustment_month_count)} contrato(s)</span>}
|
| 80 |
+
/>
|
| 81 |
+
<StatGrid
|
| 82 |
+
itens={[
|
| 83 |
+
["💰", "Total reajustado", moeda(Co.readjustment_month_total), "teal"],
|
| 84 |
+
["📈", "Reajuste médio", pct(Co.readjustment_month_avg_percent), "blue"],
|
| 85 |
+
...(Co.readjustment_inconsistent_count
|
| 86 |
+
? [["⚠️", "Possível inconsistência", num(Co.readjustment_inconsistent_count), "amber"]]
|
| 87 |
+
: []),
|
| 88 |
+
]}
|
| 89 |
+
/>
|
| 90 |
+
<div className="disclaimer">⚠️ {Co.readjustment_disclaimer}</div>
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
|
| 94 |
<div className="card">
|
| 95 |
<SecaoHeader
|
| 96 |
icone="🚨"
|
|
|
|
| 111 |
</div>
|
| 112 |
</div>
|
| 113 |
</div>
|
| 114 |
+
|
| 115 |
+
<HighlightsCards
|
| 116 |
+
highlights={dia.highlights}
|
| 117 |
+
chaves={["oportunidades_criadas", "contratos_modificados", "satisfacoes_do_dia"]}
|
| 118 |
+
/>
|
| 119 |
</>
|
| 120 |
);
|
| 121 |
}
|
src/agent/analytical_force_agent.py
CHANGED
|
@@ -19,6 +19,7 @@ import pandas as pd
|
|
| 19 |
|
| 20 |
from ..analytics import (
|
| 21 |
calculate_cancellation_metrics,
|
|
|
|
| 22 |
calculate_lead_metrics,
|
| 23 |
calculate_opportunity_metrics,
|
| 24 |
calculate_satisfaction_metrics,
|
|
@@ -289,6 +290,12 @@ class AnalyticalForceAgent:
|
|
| 289 |
"cancelamentos": extrator.extrair_cancelamentos(
|
| 290 |
dia, self._settings.cancellation_source
|
| 291 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
}
|
| 293 |
|
| 294 |
@staticmethod
|
|
@@ -364,6 +371,11 @@ class AnalyticalForceAgent:
|
|
| 364 |
previous_metrics=anterior.get("cancellations"),
|
| 365 |
seven_day_average=media7.get("cancellations"),
|
| 366 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
return {
|
| 369 |
"leads": leads,
|
|
@@ -371,6 +383,7 @@ class AnalyticalForceAgent:
|
|
| 371 |
"tasks": tarefas,
|
| 372 |
"satisfaction": satisfacao,
|
| 373 |
"cancellations": cancelamentos,
|
|
|
|
| 374 |
}
|
| 375 |
|
| 376 |
def _montar_payload(
|
|
@@ -449,6 +462,13 @@ class AnalyticalForceAgent:
|
|
| 449 |
_reg(r.get("Id"), r.get("Name")) for _, r in sem.head(limite).iterrows()
|
| 450 |
]
|
| 451 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
opp_fech = dados.get("opp_fechadas")
|
| 453 |
if opp_fech is not None and not opp_fech.empty and "IsWon" in opp_fech.columns:
|
| 454 |
ganha_bool = opp_fech["IsWon"].fillna(False).astype(bool)
|
|
@@ -497,6 +517,18 @@ class AnalyticalForceAgent:
|
|
| 497 |
and "SentimentScore" in sat.columns
|
| 498 |
):
|
| 499 |
score = pd.to_numeric(sat["SentimentScore"], errors="coerce")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
piores = sat[score <= 5].copy()
|
| 501 |
if not piores.empty:
|
| 502 |
piores["_score"] = pd.to_numeric(piores["SentimentScore"], errors="coerce")
|
|
@@ -512,6 +544,19 @@ class AnalyticalForceAgent:
|
|
| 512 |
for _, r in piores.head(limite).iterrows()
|
| 513 |
]
|
| 514 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
return destaques
|
| 516 |
|
| 517 |
@staticmethod
|
|
|
|
| 19 |
|
| 20 |
from ..analytics import (
|
| 21 |
calculate_cancellation_metrics,
|
| 22 |
+
calculate_contract_metrics,
|
| 23 |
calculate_lead_metrics,
|
| 24 |
calculate_opportunity_metrics,
|
| 25 |
calculate_satisfaction_metrics,
|
|
|
|
| 290 |
"cancelamentos": extrator.extrair_cancelamentos(
|
| 291 |
dia, self._settings.cancellation_source
|
| 292 |
),
|
| 293 |
+
"contratos_modificados": extrator.extrair_contratos_modificados(
|
| 294 |
+
dia, self._settings.contract_source
|
| 295 |
+
),
|
| 296 |
+
"contratos_reajustados": extrator.extrair_contratos_reajustados_mes(
|
| 297 |
+
dia, self._settings.contract_source
|
| 298 |
+
),
|
| 299 |
}
|
| 300 |
|
| 301 |
@staticmethod
|
|
|
|
| 371 |
previous_metrics=anterior.get("cancellations"),
|
| 372 |
seven_day_average=media7.get("cancellations"),
|
| 373 |
)
|
| 374 |
+
contratos = calculate_contract_metrics(
|
| 375 |
+
df_modificados=dados.get("contratos_modificados"),
|
| 376 |
+
df_reajustados=dados.get("contratos_reajustados"),
|
| 377 |
+
source=self._settings.contract_source,
|
| 378 |
+
)
|
| 379 |
|
| 380 |
return {
|
| 381 |
"leads": leads,
|
|
|
|
| 383 |
"tasks": tarefas,
|
| 384 |
"satisfaction": satisfacao,
|
| 385 |
"cancellations": cancelamentos,
|
| 386 |
+
"contracts": contratos,
|
| 387 |
}
|
| 388 |
|
| 389 |
def _montar_payload(
|
|
|
|
| 462 |
_reg(r.get("Id"), r.get("Name")) for _, r in sem.head(limite).iterrows()
|
| 463 |
]
|
| 464 |
|
| 465 |
+
opp_criadas = dados.get("opp_criadas")
|
| 466 |
+
if opp_criadas is not None and not opp_criadas.empty and "Id" in opp_criadas.columns:
|
| 467 |
+
destaques["oportunidades_criadas"] = [
|
| 468 |
+
_reg(r.get("Id"), r.get("Name"), self._info_moeda(r.get("Amount")))
|
| 469 |
+
for _, r in opp_criadas.head(limite).iterrows()
|
| 470 |
+
]
|
| 471 |
+
|
| 472 |
opp_fech = dados.get("opp_fechadas")
|
| 473 |
if opp_fech is not None and not opp_fech.empty and "IsWon" in opp_fech.columns:
|
| 474 |
ganha_bool = opp_fech["IsWon"].fillna(False).astype(bool)
|
|
|
|
| 517 |
and "SentimentScore" in sat.columns
|
| 518 |
):
|
| 519 |
score = pd.to_numeric(sat["SentimentScore"], errors="coerce")
|
| 520 |
+
# Registro completo do dia (não só as piores) — usado no "registro
|
| 521 |
+
# do dia" do e-mail/painel, conforme pedido do usuário.
|
| 522 |
+
destaques["satisfacoes_do_dia"] = [
|
| 523 |
+
_reg(
|
| 524 |
+
r.get("Id"),
|
| 525 |
+
r.get("Conta_Nome__c") or r.get("Name"),
|
| 526 |
+
(str(r.get("Sentimento__c")).strip() or None)
|
| 527 |
+
if r.get("Sentimento__c") is not None
|
| 528 |
+
else None,
|
| 529 |
+
)
|
| 530 |
+
for _, r in sat.head(limite).iterrows()
|
| 531 |
+
]
|
| 532 |
piores = sat[score <= 5].copy()
|
| 533 |
if not piores.empty:
|
| 534 |
piores["_score"] = pd.to_numeric(piores["SentimentScore"], errors="coerce")
|
|
|
|
| 544 |
for _, r in piores.head(limite).iterrows()
|
| 545 |
]
|
| 546 |
|
| 547 |
+
# Contratos modificados no dia — listagem "quem modificou o quê"
|
| 548 |
+
# (já vem pronta do motor de métricas de contrato).
|
| 549 |
+
contratos = (metricas.get("contracts", {}) or {}).get("modified_today") or []
|
| 550 |
+
if contratos:
|
| 551 |
+
destaques["contratos_modificados"] = [
|
| 552 |
+
_reg(
|
| 553 |
+
c.get("id"),
|
| 554 |
+
c.get("name"),
|
| 555 |
+
(f"por {c['modified_by']}" if c.get("modified_by") else None),
|
| 556 |
+
)
|
| 557 |
+
for c in contratos[:limite]
|
| 558 |
+
]
|
| 559 |
+
|
| 560 |
return destaques
|
| 561 |
|
| 562 |
@staticmethod
|
src/agent/prompt_builder.py
CHANGED
|
@@ -26,13 +26,24 @@ _DIR_PROMPTS = _RAIZ_PROJETO / "prompts"
|
|
| 26 |
SYSTEM_PROMPT = (
|
| 27 |
"Você é um analista comercial sênior. Recebe um JSON com métricas diárias "
|
| 28 |
"JÁ CALCULADAS de um CRM (Salesforce). Sua tarefa é escrever, em Português "
|
| 29 |
-
"do Brasil, um RESUMO EXECUTIVO
|
|
|
|
| 30 |
"Regras inegociáveis:\n"
|
| 31 |
"1. NUNCA recalcule, invente ou altere números. Use apenas os valores do JSON.\n"
|
| 32 |
-
"2.
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
)
|
| 37 |
|
| 38 |
# Instrução padrão que acompanha o JSON.
|
|
|
|
| 26 |
SYSTEM_PROMPT = (
|
| 27 |
"Você é um analista comercial sênior. Recebe um JSON com métricas diárias "
|
| 28 |
"JÁ CALCULADAS de um CRM (Salesforce). Sua tarefa é escrever, em Português "
|
| 29 |
+
"do Brasil, um RESUMO EXECUTIVO em formato de STORYTELLING — uma narrativa "
|
| 30 |
+
"que CONVENÇA a liderança com os dados, e não uma lista seca de números.\n"
|
| 31 |
"Regras inegociáveis:\n"
|
| 32 |
"1. NUNCA recalcule, invente ou altere números. Use apenas os valores do JSON.\n"
|
| 33 |
+
"2. Toda conclusão deve estar ligada a uma métrica ou alerta presente no JSON — "
|
| 34 |
+
"nada de generalidades sem dado por trás.\n"
|
| 35 |
+
"3. Construa uma narrativa com começo (cenário do dia), meio (o que os "
|
| 36 |
+
"números revelam — leads, oportunidades, contratos/reajustes, satisfação) "
|
| 37 |
+
"e fim (para onde a liderança deve olhar agora). Use uma progressão lógica, "
|
| 38 |
+
"não uma lista de tópicos soltos.\n"
|
| 39 |
+
"4. Use um tom direto e confiante, com dados concretos como prova (ex.: "
|
| 40 |
+
"'as oportunidades de alto valor paradas somam R$ X e concentram o risco "
|
| 41 |
+
"de hoje') — o objetivo é persuadir com evidência, não com adjetivos vazios.\n"
|
| 42 |
+
"5. Não use saudações, títulos de seção nem linguagem genérica de IA.\n"
|
| 43 |
+
"6. Produza de 3 a 5 parágrafos curtos e coesos. Não repita a tabela de métricas.\n"
|
| 44 |
+
"7. Se o JSON trouxer um aviso de qualidade de dados (ex.: reajuste de "
|
| 45 |
+
"contrato estimado), mencione a ressalva de forma transparente, sem "
|
| 46 |
+
"esconder a incerteza."
|
| 47 |
)
|
| 48 |
|
| 49 |
# Instrução padrão que acompanha o JSON.
|
src/analytics/__init__.py
CHANGED
|
@@ -9,6 +9,7 @@ from .opportunity_metrics import calculate_opportunity_metrics
|
|
| 9 |
from .task_metrics import calculate_task_metrics
|
| 10 |
from .satisfaction_metrics import calculate_satisfaction_metrics
|
| 11 |
from .cancellation_metrics import calculate_cancellation_metrics
|
|
|
|
| 12 |
from .comparison_metrics import compare_metric, aplicar_comparacoes
|
| 13 |
from .risk_engine import generate_alerts
|
| 14 |
|
|
@@ -18,6 +19,7 @@ __all__ = [
|
|
| 18 |
"calculate_task_metrics",
|
| 19 |
"calculate_satisfaction_metrics",
|
| 20 |
"calculate_cancellation_metrics",
|
|
|
|
| 21 |
"compare_metric",
|
| 22 |
"aplicar_comparacoes",
|
| 23 |
"generate_alerts",
|
|
|
|
| 9 |
from .task_metrics import calculate_task_metrics
|
| 10 |
from .satisfaction_metrics import calculate_satisfaction_metrics
|
| 11 |
from .cancellation_metrics import calculate_cancellation_metrics
|
| 12 |
+
from .contract_metrics import calculate_contract_metrics
|
| 13 |
from .comparison_metrics import compare_metric, aplicar_comparacoes
|
| 14 |
from .risk_engine import generate_alerts
|
| 15 |
|
|
|
|
| 19 |
"calculate_task_metrics",
|
| 20 |
"calculate_satisfaction_metrics",
|
| 21 |
"calculate_cancellation_metrics",
|
| 22 |
+
"calculate_contract_metrics",
|
| 23 |
"compare_metric",
|
| 24 |
"aplicar_comparacoes",
|
| 25 |
"generate_alerts",
|
src/analytics/contract_metrics.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cálculo de métricas de Contratos (``Contrato_oPT__c``).
|
| 2 |
+
|
| 3 |
+
Calcula, a partir dos DataFrames já extraídos do Salesforce:
|
| 4 |
+
|
| 5 |
+
- quantos contratos foram modificados no dia e quem modificou cada um
|
| 6 |
+
(usa apenas campos padrão: ``LastModifiedDate``/``LastModifiedBy``);
|
| 7 |
+
- o total de reajuste aplicado no MÊS corrente, quando o módulo configurável
|
| 8 |
+
de reajuste estiver ativo (``value_field``/``previous_value_field``);
|
| 9 |
+
- um alerta de qualidade de dados quando o reajuste informado no contrato
|
| 10 |
+
diverge do delta calculado (valor atual - valor anterior), que costuma
|
| 11 |
+
indicar erro de preenchimento (ex.: valor anterior errado).
|
| 12 |
+
|
| 13 |
+
Segue o princípio do projeto: Python calcula, a IA apenas interpreta o JSON
|
| 14 |
+
resultante. Este módulo NUNCA deve ser chamado pela camada de IA.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
import pandas as pd
|
| 22 |
+
|
| 23 |
+
# Aviso fixo, exibido sempre que o módulo de reajuste está ativo — o cálculo
|
| 24 |
+
# é uma ESTIMATIVA (delta de valores), não um dado oficial do contrato.
|
| 25 |
+
AVISO_REAJUSTE = (
|
| 26 |
+
"Os valores de reajuste são estimados pela diferença entre o valor atual "
|
| 27 |
+
"e o valor anterior do contrato. Podem não refletir com 100% de precisão "
|
| 28 |
+
"o reajuste real caso haja erro de preenchimento no contrato (ex.: valor "
|
| 29 |
+
"anterior lançado incorretamente)."
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Tolerância (em R$) para considerar o reajuste informado e o delta calculado
|
| 33 |
+
# como equivalentes — acima disso, o contrato é sinalizado como inconsistente.
|
| 34 |
+
_TOLERANCIA_CENTAVOS = 0.01
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _valor_relacionamento(linha: Any, campo: str) -> Any:
|
| 38 |
+
"""Resolve um campo simples ou de relacionamento (ex.: ``Conta__r.Name``).
|
| 39 |
+
|
| 40 |
+
O Salesforce retorna relacionamentos como dicionários aninhados (ex.:
|
| 41 |
+
``{"LastModifiedBy": {"Name": "Fulano", "attributes": {...}}}``), não como
|
| 42 |
+
chave plana ``"LastModifiedBy.Name"``. Esta função navega essa estrutura.
|
| 43 |
+
"""
|
| 44 |
+
atual: Any = linha
|
| 45 |
+
for parte in campo.split("."):
|
| 46 |
+
if atual is None:
|
| 47 |
+
return None
|
| 48 |
+
if isinstance(atual, dict):
|
| 49 |
+
atual = atual.get(parte)
|
| 50 |
+
elif hasattr(atual, "get"):
|
| 51 |
+
atual = atual.get(parte)
|
| 52 |
+
else:
|
| 53 |
+
return None
|
| 54 |
+
return atual
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _serializar_data(valor: Any) -> str | None:
|
| 58 |
+
"""Converte um valor de data/datetime em texto ISO, tolerando nulos."""
|
| 59 |
+
if valor is None:
|
| 60 |
+
return None
|
| 61 |
+
try:
|
| 62 |
+
if pd.isna(valor):
|
| 63 |
+
return None
|
| 64 |
+
except (TypeError, ValueError):
|
| 65 |
+
pass
|
| 66 |
+
try:
|
| 67 |
+
return valor.isoformat()
|
| 68 |
+
except AttributeError:
|
| 69 |
+
texto = str(valor).strip()
|
| 70 |
+
return texto or None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _listar_contratos_modificados(
|
| 74 |
+
df: pd.DataFrame, account_name_field: str
|
| 75 |
+
) -> list[dict[str, Any]]:
|
| 76 |
+
"""Monta a listagem 'quem modificou o quê' dos contratos do dia."""
|
| 77 |
+
if df is None or df.empty:
|
| 78 |
+
return []
|
| 79 |
+
itens: list[dict[str, Any]] = []
|
| 80 |
+
for _, linha in df.iterrows():
|
| 81 |
+
item: dict[str, Any] = {
|
| 82 |
+
"id": linha.get("Id"),
|
| 83 |
+
"name": linha.get("Name") or linha.get("Id"),
|
| 84 |
+
"modified_by": _valor_relacionamento(linha, "LastModifiedBy.Name"),
|
| 85 |
+
"modified_at": _serializar_data(linha.get("LastModifiedDate")),
|
| 86 |
+
}
|
| 87 |
+
if account_name_field:
|
| 88 |
+
item["account_name"] = _valor_relacionamento(linha, account_name_field)
|
| 89 |
+
itens.append(item)
|
| 90 |
+
return itens
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def calculate_contract_metrics(
|
| 94 |
+
df_modificados: pd.DataFrame | None,
|
| 95 |
+
df_reajustados: pd.DataFrame | None,
|
| 96 |
+
source: dict[str, Any] | None,
|
| 97 |
+
) -> dict[str, Any]:
|
| 98 |
+
"""Calcula as métricas diárias de contratos (modificações + reajuste).
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
df_modificados: Contratos com ``LastModifiedDate`` no dia de referência.
|
| 102 |
+
df_reajustados: Contratos com reajuste aplicado no mês corrente (vazio
|
| 103 |
+
se o módulo de reajuste não estiver configurado).
|
| 104 |
+
source: Configuração ``contract_source`` (ver ``config.settings``).
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Dicionário JSON-serializável com as métricas já calculadas, pronto
|
| 108 |
+
para persistência no Turso e uso pelo relatório/e-mail/painel.
|
| 109 |
+
"""
|
| 110 |
+
source = source or {}
|
| 111 |
+
account_name_field = source.get("account_name_field") or ""
|
| 112 |
+
value_field = source.get("value_field") or ""
|
| 113 |
+
previous_field = source.get("previous_value_field") or ""
|
| 114 |
+
readjustment_field = source.get("readjustment_field") or ""
|
| 115 |
+
readjustment_configured = bool(
|
| 116 |
+
value_field and previous_field and source.get("readjustment_date_field")
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
modificados = _listar_contratos_modificados(df_modificados, account_name_field)
|
| 120 |
+
|
| 121 |
+
resultado: dict[str, Any] = {
|
| 122 |
+
"configured": bool(source.get("object")),
|
| 123 |
+
"modified_today_count": len(modificados),
|
| 124 |
+
"modified_today": modificados,
|
| 125 |
+
"readjustment_configured": readjustment_configured,
|
| 126 |
+
"readjustment_month_count": 0,
|
| 127 |
+
"readjustment_month_total": 0.0,
|
| 128 |
+
"readjustment_month_avg_percent": None,
|
| 129 |
+
"readjustment_inconsistent_count": 0,
|
| 130 |
+
"readjustment_contracts": [],
|
| 131 |
+
"readjustment_disclaimer": AVISO_REAJUSTE,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
if not readjustment_configured or df_reajustados is None or df_reajustados.empty:
|
| 135 |
+
return resultado
|
| 136 |
+
|
| 137 |
+
df = df_reajustados.copy()
|
| 138 |
+
df["_valor_atual"] = pd.to_numeric(df.get(value_field), errors="coerce")
|
| 139 |
+
df["_valor_anterior"] = pd.to_numeric(df.get(previous_field), errors="coerce")
|
| 140 |
+
validos = df.dropna(subset=["_valor_atual", "_valor_anterior"])
|
| 141 |
+
if validos.empty:
|
| 142 |
+
return resultado
|
| 143 |
+
|
| 144 |
+
validos = validos.copy()
|
| 145 |
+
validos["_delta"] = validos["_valor_atual"] - validos["_valor_anterior"]
|
| 146 |
+
if readjustment_field and readjustment_field in validos.columns:
|
| 147 |
+
validos["_informado"] = pd.to_numeric(validos[readjustment_field], errors="coerce")
|
| 148 |
+
else:
|
| 149 |
+
validos["_informado"] = validos["_delta"]
|
| 150 |
+
|
| 151 |
+
contratos: list[dict[str, Any]] = []
|
| 152 |
+
percentuais: list[float] = []
|
| 153 |
+
inconsistentes = 0
|
| 154 |
+
for _, linha in validos.iterrows():
|
| 155 |
+
anterior = float(linha["_valor_anterior"])
|
| 156 |
+
atual = float(linha["_valor_atual"])
|
| 157 |
+
delta = float(linha["_delta"])
|
| 158 |
+
informado = linha.get("_informado")
|
| 159 |
+
informado_val = None if informado is None or pd.isna(informado) else float(informado)
|
| 160 |
+
inconsistente = (
|
| 161 |
+
informado_val is not None
|
| 162 |
+
and abs(informado_val - delta) > _TOLERANCIA_CENTAVOS
|
| 163 |
+
)
|
| 164 |
+
if inconsistente:
|
| 165 |
+
inconsistentes += 1
|
| 166 |
+
pct = (delta / anterior * 100.0) if anterior else None
|
| 167 |
+
if pct is not None:
|
| 168 |
+
percentuais.append(pct)
|
| 169 |
+
item: dict[str, Any] = {
|
| 170 |
+
"id": linha.get("Id"),
|
| 171 |
+
"name": linha.get("Name") or linha.get("Id"),
|
| 172 |
+
"previous_value": anterior,
|
| 173 |
+
"current_value": atual,
|
| 174 |
+
"delta": delta,
|
| 175 |
+
"reported_readjustment": informado_val,
|
| 176 |
+
"percent": pct,
|
| 177 |
+
"inconsistent": inconsistente,
|
| 178 |
+
}
|
| 179 |
+
if account_name_field:
|
| 180 |
+
item["account_name"] = _valor_relacionamento(linha, account_name_field)
|
| 181 |
+
contratos.append(item)
|
| 182 |
+
|
| 183 |
+
resultado["readjustment_month_count"] = len(contratos)
|
| 184 |
+
resultado["readjustment_month_total"] = float(validos["_delta"].sum())
|
| 185 |
+
resultado["readjustment_month_avg_percent"] = (
|
| 186 |
+
sum(percentuais) / len(percentuais) if percentuais else None
|
| 187 |
+
)
|
| 188 |
+
resultado["readjustment_inconsistent_count"] = inconsistentes
|
| 189 |
+
# Ordena por delta (maior reajuste primeiro) — mais relevante para leitura.
|
| 190 |
+
resultado["readjustment_contracts"] = sorted(
|
| 191 |
+
contratos, key=lambda c: abs(c["delta"]), reverse=True
|
| 192 |
+
)
|
| 193 |
+
return resultado
|
src/config/settings.py
CHANGED
|
@@ -555,10 +555,10 @@ def _carregar_fonte_cancelamento() -> dict[str, Any]:
|
|
| 555 |
|
| 556 |
|
| 557 |
def _carregar_fonte_contrato() -> dict[str, Any]:
|
| 558 |
-
"""Monta a configuração da fonte de Contratos (objeto + vínculos).
|
| 559 |
|
| 560 |
-
Diferente de satisfação/cancelamento,
|
| 561 |
-
|
| 562 |
itens de contrato e clientes). Os campos de vínculo (``account_field`` e
|
| 563 |
``item_parent_field``) são o NOME DE API do campo de lookup na org; como
|
| 564 |
o projeto não pode inventar nomes de campo (regra 3), eles ficam vazios
|
|
@@ -567,6 +567,13 @@ def _carregar_fonte_contrato() -> dict[str, Any]:
|
|
| 567 |
|
| 568 |
Padrões de objeto alinhados à org Penso (``Contrato_oPT__c`` e
|
| 569 |
``Item_do_Contrato__c``); ajuste via env se sua org usar outros nomes.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
"""
|
| 571 |
return {
|
| 572 |
"object": _get_str("SF_CONTRACT_OBJECT", "Contrato_oPT__c"),
|
|
@@ -574,9 +581,23 @@ def _carregar_fonte_contrato() -> dict[str, Any]:
|
|
| 574 |
# Lookup do Contrato -> Account (nome de API do campo, ex.: "Conta__c").
|
| 575 |
# Vazio = busca de contratos por cliente fica desativada.
|
| 576 |
"account_field": _get_str("SF_CONTRACT_ACCOUNT_FIELD", ""),
|
|
|
|
|
|
|
|
|
|
| 577 |
# Lookup do Item de Contrato -> Contrato (nome de API do campo).
|
| 578 |
# Vazio = a tela de contrato não lista os itens vinculados.
|
| 579 |
"item_parent_field": _get_str("SF_CONTRACT_ITEM_PARENT_FIELD", ""),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
}
|
| 581 |
|
| 582 |
|
|
|
|
| 555 |
|
| 556 |
|
| 557 |
def _carregar_fonte_contrato() -> dict[str, Any]:
|
| 558 |
+
"""Monta a configuração da fonte de Contratos (objeto + vínculos + reajuste).
|
| 559 |
|
| 560 |
+
Diferente de satisfação/cancelamento, os campos de objeto/vínculo desta
|
| 561 |
+
fonte alimentam o módulo de CONSULTA (busca somente leitura de contratos,
|
| 562 |
itens de contrato e clientes). Os campos de vínculo (``account_field`` e
|
| 563 |
``item_parent_field``) são o NOME DE API do campo de lookup na org; como
|
| 564 |
o projeto não pode inventar nomes de campo (regra 3), eles ficam vazios
|
|
|
|
| 567 |
|
| 568 |
Padrões de objeto alinhados à org Penso (``Contrato_oPT__c`` e
|
| 569 |
``Item_do_Contrato__c``); ajuste via env se sua org usar outros nomes.
|
| 570 |
+
|
| 571 |
+
Já o bloco de REAJUSTE (``value_field``/``previous_value_field``/
|
| 572 |
+
``readjustment_field``/``readjustment_date_field``) alimenta o pipeline
|
| 573 |
+
diário (relatório/e-mail): módulo configurável (regra 16/17) — vazio por
|
| 574 |
+
padrão, pois os nomes reais desses campos na org Penso não são conhecidos
|
| 575 |
+
e não podem ser adivinhados. Sem essa configuração, a análise de reajuste
|
| 576 |
+
permanece desativada (sem quebrar o restante do relatório).
|
| 577 |
"""
|
| 578 |
return {
|
| 579 |
"object": _get_str("SF_CONTRACT_OBJECT", "Contrato_oPT__c"),
|
|
|
|
| 581 |
# Lookup do Contrato -> Account (nome de API do campo, ex.: "Conta__c").
|
| 582 |
# Vazio = busca de contratos por cliente fica desativada.
|
| 583 |
"account_field": _get_str("SF_CONTRACT_ACCOUNT_FIELD", ""),
|
| 584 |
+
# Campo de relacionamento para exibir o NOME da conta (ex.: "Conta__r.Name").
|
| 585 |
+
# Vazio = listagens de contrato não mostram o nome do cliente.
|
| 586 |
+
"account_name_field": _get_str("SF_CONTRACT_ACCOUNT_NAME_FIELD", ""),
|
| 587 |
# Lookup do Item de Contrato -> Contrato (nome de API do campo).
|
| 588 |
# Vazio = a tela de contrato não lista os itens vinculados.
|
| 589 |
"item_parent_field": _get_str("SF_CONTRACT_ITEM_PARENT_FIELD", ""),
|
| 590 |
+
# ---- Módulo configurável de REAJUSTE (vazio = desativado) ----
|
| 591 |
+
# Campo com o valor ATUAL do contrato (ex.: "Valor_Atual__c").
|
| 592 |
+
"value_field": _get_str("SF_CONTRACT_VALUE_FIELD", ""),
|
| 593 |
+
# Campo com o valor ANTERIOR ao reajuste (ex.: "Valor_Anterior__c").
|
| 594 |
+
"previous_value_field": _get_str("SF_CONTRACT_PREVIOUS_VALUE_FIELD", ""),
|
| 595 |
+
# Campo com o valor de reajuste já informado no contrato (opcional).
|
| 596 |
+
# Se vazio, o reajuste é ESTIMADO como (valor atual - valor anterior).
|
| 597 |
+
"readjustment_field": _get_str("SF_CONTRACT_READJUSTMENT_FIELD", ""),
|
| 598 |
+
# Campo de data em que o reajuste foi aplicado (usado para filtrar
|
| 599 |
+
# "reajustes do mês"). Sem ele, o módulo de reajuste fica desativado.
|
| 600 |
+
"readjustment_date_field": _get_str("SF_CONTRACT_READJUSTMENT_DATE_FIELD", ""),
|
| 601 |
}
|
| 602 |
|
| 603 |
|
src/delivery/email_sender.py
CHANGED
|
@@ -152,11 +152,16 @@ def _md_para_html(texto: str) -> str:
|
|
| 152 |
# Blocos do e-mail HTML
|
| 153 |
# ----------------------------------------------------------------------
|
| 154 |
def _linha_kpi(rotulo: str, valor: str, destaque: str = _PENSO_BLUE) -> str:
|
| 155 |
-
"""Célula de KPI (rótulo + valor) para a grade de números-chave.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
return (
|
| 157 |
-
'<td style="padding:10px 14px;border:1px solid #e6e8eb;'
|
| 158 |
'border-radius:8px;background:#f8fafc;vertical-align:top;">'
|
| 159 |
-
f'<div style="font-size:11px;color:#64748b;text-transform:uppercase;'
|
| 160 |
f'letter-spacing:.04em;">{_escape(rotulo)}</div>'
|
| 161 |
f'<div style="font-size:20px;font-weight:700;color:{destaque};'
|
| 162 |
f'margin-top:2px;">{_escape(valor)}</div></td>'
|
|
@@ -164,12 +169,16 @@ def _linha_kpi(rotulo: str, valor: str, destaque: str = _PENSO_BLUE) -> str:
|
|
| 164 |
|
| 165 |
|
| 166 |
def _grade_kpis(metrics: dict[str, Any]) -> str:
|
| 167 |
-
"""Monta a grade de números-chave (3 colunas por linha).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
leads = metrics.get("leads", {}) or {}
|
| 169 |
opp = metrics.get("opportunities", {}) or {}
|
| 170 |
-
tasks = metrics.get("tasks", {}) or {}
|
| 171 |
sat = metrics.get("satisfaction", {}) or {}
|
| 172 |
canc = metrics.get("cancellations", {}) or {}
|
|
|
|
| 173 |
|
| 174 |
celulas: list[str] = [
|
| 175 |
_linha_kpi("Leads novos", _num(leads.get("new_leads"))),
|
|
@@ -178,10 +187,10 @@ def _grade_kpis(metrics: dict[str, Any]) -> str:
|
|
| 178 |
"Pipeline aberto",
|
| 179 |
_moeda(opp.get("open_pipeline_product_value") or opp.get("open_pipeline_amount")),
|
| 180 |
),
|
|
|
|
| 181 |
_linha_kpi("Oportunidades ganhas", _num(opp.get("won_opportunities")), _SUCESSO_TXT),
|
| 182 |
_linha_kpi("Oportunidades perdidas", _num(opp.get("lost_opportunities")), _ERRO_TXT),
|
| 183 |
_linha_kpi("Oportunidades paradas", _num(opp.get("stalled_opportunities")), _ALERTA_TXT),
|
| 184 |
-
_linha_kpi("Tarefas vencidas", _num(tasks.get("tasks_overdue")), _ALERTA_TXT),
|
| 185 |
]
|
| 186 |
if sat.get("configured") and sat.get("responses"):
|
| 187 |
celulas.append(_linha_kpi("Satisfação (nota média)", _num(sat.get("avg_score"))))
|
|
@@ -189,6 +198,8 @@ def _grade_kpis(metrics: dict[str, Any]) -> str:
|
|
| 189 |
celulas.append(
|
| 190 |
_linha_kpi("Cancelamentos", _num(canc.get("cancellations_count")), _ERRO_TXT)
|
| 191 |
)
|
|
|
|
|
|
|
| 192 |
|
| 193 |
# Distribui as células em linhas de 3 colunas.
|
| 194 |
linhas_html: list[str] = []
|
|
@@ -208,6 +219,85 @@ def _grade_kpis(metrics: dict[str, Any]) -> str:
|
|
| 208 |
)
|
| 209 |
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
def _bloco_alertas(alerts: list[dict[str, Any]]) -> str:
|
| 212 |
"""Monta os cartões dos principais alertas (até 6)."""
|
| 213 |
altos = [a for a in alerts if a.get("severity") == "high"]
|
|
@@ -262,6 +352,44 @@ def _bloco_prioridades(alerts: list[dict[str, Any]]) -> str:
|
|
| 262 |
)
|
| 263 |
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
def _montar_html(
|
| 266 |
report_date: str,
|
| 267 |
metrics: dict[str, Any],
|
|
@@ -269,7 +397,12 @@ def _montar_html(
|
|
| 269 |
resumo_ia: str = "",
|
| 270 |
highlights: dict[str, list[dict[str, Any]]] | None = None,
|
| 271 |
) -> str:
|
| 272 |
-
"""Monta o
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
altos = sum(1 for a in alerts if a.get("severity") == "high")
|
| 274 |
resumo = (
|
| 275 |
f"Foram gerados <strong>{len(alerts)} alerta(s)</strong>, "
|
|
@@ -281,7 +414,7 @@ def _montar_html(
|
|
| 281 |
if corpo_ia:
|
| 282 |
bloco_ia = (
|
| 283 |
f"<div style='background:{_HOVER};border:1px solid {_DIVISORIA};border-left:4px solid {_PENSO_BLUE};"
|
| 284 |
-
"border-radius:10px;padding:14px 16px;margin:0 0 18px;'>"
|
| 285 |
f"<div style='font-size:12px;color:{_PENSO_BLUE};font-weight:700;text-transform:uppercase;"
|
| 286 |
"letter-spacing:.04em;margin-bottom:8px;'>🧠 Análise do dia</div>"
|
| 287 |
f"{corpo_ia}"
|
|
@@ -292,7 +425,7 @@ def _montar_html(
|
|
| 292 |
# este bloco por um <img> com o PNG/SVG oficial (base64 ou hospedado).
|
| 293 |
# Por ora, usa apenas cores da marca (Penso Blue/Black) — sem inventar formas.
|
| 294 |
cabecalho = f"""\
|
| 295 |
-
<td style="background:linear-gradient(120deg,{_BLACK_INTENSE},{_BLUE_TECH} 55%,{_PENSO_BLUE});padding:26px 28px;">
|
| 296 |
<table role="presentation" cellspacing="0" cellpadding="0"><tr>
|
| 297 |
<td style="width:34px;height:34px;background:#ffffff;border-radius:9px;text-align:center;
|
| 298 |
vertical-align:middle;font-weight:800;font-size:16px;color:{_PENSO_BLUE};">P</td>
|
|
@@ -304,27 +437,36 @@ def _montar_html(
|
|
| 304 |
font-size:12px;font-weight:600;padding:5px 12px;border-radius:999px;">📅 {_escape(report_date)}</span>
|
| 305 |
</td>"""
|
| 306 |
rodape = f"""\
|
| 307 |
-
<td style="background:{_HOVER};padding:16px 28px;border-top:1px solid {_DIVISORIA};">
|
| 308 |
<table role="presentation" cellspacing="0" cellpadding="0"><tr>
|
| 309 |
<td style="font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em;padding-right:8px;">Fonte de dados</td>
|
| 310 |
<td><img src="{_LOGO_SALESFORCE_URL}" alt="Salesforce" style="height:14px;vertical-align:middle;" /></td>
|
| 311 |
</tr></table>
|
| 312 |
-
<div style="font-size:12px;color:#94a3b8;margin-top:10px;">
|
| 313 |
Relatório gerado automaticamente pelo agente <strong>Analytical-Force</strong> (Penso).
|
| 314 |
Os números são calculados em Python a partir do Salesforce; a interpretação
|
| 315 |
é gerada por modelo local. O relatório completo está anexado/registrado no sistema.
|
| 316 |
</div>
|
| 317 |
</td>"""
|
| 318 |
return f"""\
|
| 319 |
-
<
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
<tr>
|
| 324 |
{cabecalho}
|
| 325 |
</tr>
|
| 326 |
<tr>
|
| 327 |
-
<td style="padding:24px 28px;">
|
| 328 |
<h3 style="font-size:14px;color:#0f172a;margin:0 0 10px;padding-left:10px;border-left:3px solid {_PENSO_BLUE};">Resumo executivo</h3>
|
| 329 |
<p style="font-size:14px;color:#475569;margin:0 0 14px;line-height:1.55;">{resumo}</p>
|
| 330 |
{bloco_ia}
|
|
@@ -332,6 +474,8 @@ def _montar_html(
|
|
| 332 |
<h3 style="font-size:14px;color:#0f172a;margin:22px 0 10px;padding-left:10px;border-left:3px solid {_PENSO_BLUE};">Números-chave</h3>
|
| 333 |
{_grade_kpis(metrics)}
|
| 334 |
|
|
|
|
|
|
|
| 335 |
<h3 style="font-size:14px;color:#0f172a;margin:22px 0 10px;padding-left:10px;border-left:3px solid {_ERRO};">Principais alertas</h3>
|
| 336 |
{_bloco_alertas(alerts)}
|
| 337 |
|
|
@@ -343,8 +487,12 @@ def _montar_html(
|
|
| 343 |
<tr>
|
| 344 |
{rodape}
|
| 345 |
</tr>
|
|
|
|
|
|
|
| 346 |
</table>
|
| 347 |
-
</div>
|
|
|
|
|
|
|
| 348 |
|
| 349 |
|
| 350 |
def _montar_texto(
|
|
@@ -408,11 +556,14 @@ def _extrair_resumo_ia(report_markdown: str) -> str:
|
|
| 408 |
_ROTULO_DESTAQUE = {
|
| 409 |
"leads_criados": "🆕 Leads criados",
|
| 410 |
"leads_sem_tarefa": "⏳ Leads sem 1ª tarefa",
|
|
|
|
| 411 |
"oportunidades_travadas": "🚧 Oportunidades travadas",
|
| 412 |
"oportunidades_ganhas": "🏆 Oportunidades ganhas",
|
| 413 |
"oportunidades_perdidas": "📉 Oportunidades perdidas",
|
| 414 |
"cancelamentos": "❌ Cancelamentos",
|
|
|
|
| 415 |
"satisfacoes_piores": "😟 Piores satisfações",
|
|
|
|
| 416 |
}
|
| 417 |
|
| 418 |
|
|
|
|
| 152 |
# Blocos do e-mail HTML
|
| 153 |
# ----------------------------------------------------------------------
|
| 154 |
def _linha_kpi(rotulo: str, valor: str, destaque: str = _PENSO_BLUE) -> str:
|
| 155 |
+
"""Célula de KPI (rótulo + valor) para a grade de números-chave.
|
| 156 |
+
|
| 157 |
+
Recebe a classe ``af-kpi-cell`` (dark mode + empilhamento em telas
|
| 158 |
+
pequenas via CSS em ``<head>``) além do estilo inline (fallback para
|
| 159 |
+
clientes que ignoram ``<style>``, ex.: Outlook desktop).
|
| 160 |
+
"""
|
| 161 |
return (
|
| 162 |
+
'<td class="af-kpi-cell" style="padding:10px 14px;border:1px solid #e6e8eb;'
|
| 163 |
'border-radius:8px;background:#f8fafc;vertical-align:top;">'
|
| 164 |
+
f'<div class="af-kpi-label" style="font-size:11px;color:#64748b;text-transform:uppercase;'
|
| 165 |
f'letter-spacing:.04em;">{_escape(rotulo)}</div>'
|
| 166 |
f'<div style="font-size:20px;font-weight:700;color:{destaque};'
|
| 167 |
f'margin-top:2px;">{_escape(valor)}</div></td>'
|
|
|
|
| 169 |
|
| 170 |
|
| 171 |
def _grade_kpis(metrics: dict[str, Any]) -> str:
|
| 172 |
+
"""Monta a grade de números-chave (3 colunas por linha, empilha no mobile).
|
| 173 |
+
|
| 174 |
+
Não inclui mais "Tarefas vencidas" (removido a pedido — volume alto
|
| 175 |
+
demais para um KPI de topo); em seu lugar entram "Oportunidades criadas".
|
| 176 |
+
"""
|
| 177 |
leads = metrics.get("leads", {}) or {}
|
| 178 |
opp = metrics.get("opportunities", {}) or {}
|
|
|
|
| 179 |
sat = metrics.get("satisfaction", {}) or {}
|
| 180 |
canc = metrics.get("cancellations", {}) or {}
|
| 181 |
+
contratos = metrics.get("contracts", {}) or {}
|
| 182 |
|
| 183 |
celulas: list[str] = [
|
| 184 |
_linha_kpi("Leads novos", _num(leads.get("new_leads"))),
|
|
|
|
| 187 |
"Pipeline aberto",
|
| 188 |
_moeda(opp.get("open_pipeline_product_value") or opp.get("open_pipeline_amount")),
|
| 189 |
),
|
| 190 |
+
_linha_kpi("Oportunidades criadas", _num(opp.get("new_opportunities"))),
|
| 191 |
_linha_kpi("Oportunidades ganhas", _num(opp.get("won_opportunities")), _SUCESSO_TXT),
|
| 192 |
_linha_kpi("Oportunidades perdidas", _num(opp.get("lost_opportunities")), _ERRO_TXT),
|
| 193 |
_linha_kpi("Oportunidades paradas", _num(opp.get("stalled_opportunities")), _ALERTA_TXT),
|
|
|
|
| 194 |
]
|
| 195 |
if sat.get("configured") and sat.get("responses"):
|
| 196 |
celulas.append(_linha_kpi("Satisfação (nota média)", _num(sat.get("avg_score"))))
|
|
|
|
| 198 |
celulas.append(
|
| 199 |
_linha_kpi("Cancelamentos", _num(canc.get("cancellations_count")), _ERRO_TXT)
|
| 200 |
)
|
| 201 |
+
if contratos.get("configured"):
|
| 202 |
+
celulas.append(_linha_kpi("Contratos modificados hoje", _num(contratos.get("modified_today_count"))))
|
| 203 |
|
| 204 |
# Distribui as células em linhas de 3 colunas.
|
| 205 |
linhas_html: list[str] = []
|
|
|
|
| 219 |
)
|
| 220 |
|
| 221 |
|
| 222 |
+
def _bloco_contratos(metrics: dict[str, Any]) -> str:
|
| 223 |
+
"""Bloco de Contratos: modificações do dia + reajuste do mês (se configurado).
|
| 224 |
+
|
| 225 |
+
Sempre traz o aviso de que o valor de reajuste é uma ESTIMATIVA (delta de
|
| 226 |
+
valores), nunca um dado 100% garantido — regra 3/16/17 do projeto.
|
| 227 |
+
"""
|
| 228 |
+
m = metrics.get("contracts", {}) or {}
|
| 229 |
+
if not m.get("configured"):
|
| 230 |
+
return ""
|
| 231 |
+
|
| 232 |
+
partes: list[str] = []
|
| 233 |
+
modificados = m.get("modified_today") or []
|
| 234 |
+
if modificados:
|
| 235 |
+
itens = "".join(
|
| 236 |
+
f"<li style='margin-bottom:3px'>"
|
| 237 |
+
f"<strong>{_escape(c.get('name') or c.get('id') or '—')}</strong>"
|
| 238 |
+
f" <span style='color:#64748b'>— modificado por "
|
| 239 |
+
f"{_escape(c.get('modified_by') or 'responsável não identificado')}</span></li>"
|
| 240 |
+
for c in modificados[:15]
|
| 241 |
+
)
|
| 242 |
+
partes.append(
|
| 243 |
+
f"<div class='af-altcard' style='background:{_HOVER};border-radius:10px;padding:12px 14px;margin-bottom:10px;'>"
|
| 244 |
+
f"<div style='font-size:13px;font-weight:700;color:#0f172a;margin-bottom:6px;'>"
|
| 245 |
+
f"📄 Contratos modificados hoje "
|
| 246 |
+
f"<span style='color:#94a3b8;font-weight:400'>({m.get('modified_today_count', 0)})</span></div>"
|
| 247 |
+
f"<ul style='margin:0;padding-left:18px;font-size:13px;color:#334155'>{itens}</ul>"
|
| 248 |
+
f"</div>"
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
if m.get("readjustment_configured"):
|
| 252 |
+
qtd = m.get("readjustment_month_count") or 0
|
| 253 |
+
if qtd:
|
| 254 |
+
inconsist = int(m.get("readjustment_inconsistent_count") or 0)
|
| 255 |
+
reaj_itens = "".join(
|
| 256 |
+
f"<li style='margin-bottom:3px'>"
|
| 257 |
+
f"<strong>{_escape(c.get('name') or c.get('id') or '—')}</strong>: "
|
| 258 |
+
f"{_moeda(c.get('previous_value'))} → {_moeda(c.get('current_value'))} "
|
| 259 |
+
f"(<span style='color:{_SUCESSO_TXT if (c.get('delta') or 0) >= 0 else _ERRO_TXT}'>"
|
| 260 |
+
f"{_moeda(c.get('delta'))}</span>"
|
| 261 |
+
+ (
|
| 262 |
+
f" · <span style='color:{_ALERTA_TXT}'>⚠ possível inconsistência</span>"
|
| 263 |
+
if c.get("inconsistent")
|
| 264 |
+
else ""
|
| 265 |
+
)
|
| 266 |
+
+ ")</li>"
|
| 267 |
+
for c in (m.get("readjustment_contracts") or [])[:10]
|
| 268 |
+
)
|
| 269 |
+
partes.append(
|
| 270 |
+
f"<div class='af-altcard' style='background:{_HOVER};border-radius:10px;padding:12px 14px;margin-bottom:10px;'>"
|
| 271 |
+
f"<div style='font-size:13px;font-weight:700;color:#0f172a;margin-bottom:6px;'>"
|
| 272 |
+
f"💹 Reajuste de contratos no mês "
|
| 273 |
+
f"<span style='color:#94a3b8;font-weight:400'>({qtd} contrato(s) · "
|
| 274 |
+
f"{_moeda(m.get('readjustment_month_total'))})</span></div>"
|
| 275 |
+
f"<ul style='margin:0 0 8px;padding-left:18px;font-size:13px;color:#334155'>{reaj_itens}</ul>"
|
| 276 |
+
+ (
|
| 277 |
+
f"<div style='font-size:12px;color:{_ALERTA_TXT};margin-bottom:6px;'>"
|
| 278 |
+
f"⚠ {inconsist} contrato(s) com possível inconsistência entre o reajuste "
|
| 279 |
+
"informado e o valor calculado.</div>"
|
| 280 |
+
if inconsist
|
| 281 |
+
else ""
|
| 282 |
+
)
|
| 283 |
+
+ f"<div style='font-size:11px;color:#94a3b8;'>⚠ {_escape(m.get('readjustment_disclaimer', ''))}</div>"
|
| 284 |
+
+ "</div>"
|
| 285 |
+
)
|
| 286 |
+
else:
|
| 287 |
+
partes.append(
|
| 288 |
+
f"<div style='font-size:12px;color:#94a3b8;margin-bottom:10px;'>"
|
| 289 |
+
"Nenhum contrato com reajuste identificado no mês corrente.</div>"
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
if not partes:
|
| 293 |
+
return ""
|
| 294 |
+
return (
|
| 295 |
+
"<h3 class='af-h' style=\"font-size:14px;color:#0f172a;margin:22px 0 10px;padding-left:10px;"
|
| 296 |
+
f"border-left:3px solid {_PENSO_BLUE};\">📄 Contratos</h3>"
|
| 297 |
+
+ "".join(partes)
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
def _bloco_alertas(alerts: list[dict[str, Any]]) -> str:
|
| 302 |
"""Monta os cartões dos principais alertas (até 6)."""
|
| 303 |
altos = [a for a in alerts if a.get("severity") == "high"]
|
|
|
|
| 352 |
)
|
| 353 |
|
| 354 |
|
| 355 |
+
def _estilo_cabecalho() -> str:
|
| 356 |
+
"""CSS embutido no ``<head>``: dark mode, responsivo e animação sutil.
|
| 357 |
+
|
| 358 |
+
Usa classes com ``!important`` para sobrepor os estilos inline em
|
| 359 |
+
clientes que suportam ``<style>``/media queries (Apple Mail, Gmail
|
| 360 |
+
app/web, Outlook.com/iOS). Clientes que ignoram ``<style>`` (ex.: Outlook
|
| 361 |
+
desktop clássico) continuam vendo o layout claro fixo via inline — nunca
|
| 362 |
+
quebram, apenas não ganham o tema escuro/empilhamento.
|
| 363 |
+
"""
|
| 364 |
+
return f"""\
|
| 365 |
+
<meta name="color-scheme" content="light dark">
|
| 366 |
+
<meta name="supported-color-scheme" content="light dark">
|
| 367 |
+
<style>
|
| 368 |
+
body {{ margin:0; padding:0; }}
|
| 369 |
+
@keyframes afFade {{ from {{ opacity:0; transform:translateY(6px); }} to {{ opacity:1; transform:translateY(0); }} }}
|
| 370 |
+
.af-animate {{ animation:afFade .5s ease-out both; }}
|
| 371 |
+
.af-animate2 {{ animation:afFade .5s ease-out .12s both; }}
|
| 372 |
+
.af-animate3 {{ animation:afFade .5s ease-out .24s both; }}
|
| 373 |
+
@media screen and (max-width:600px) {{
|
| 374 |
+
.af-container {{ width:100% !important; max-width:100% !important; border-radius:0 !important; }}
|
| 375 |
+
.af-px {{ padding-left:18px !important; padding-right:18px !important; }}
|
| 376 |
+
.af-kpi-cell {{ display:block !important; width:100% !important; margin-bottom:8px !important; }}
|
| 377 |
+
}}
|
| 378 |
+
@media (prefers-color-scheme: dark) {{
|
| 379 |
+
.af-wrap {{ background:#0a0e18 !important; }}
|
| 380 |
+
.af-card {{ background:#141a2b !important; border-color:#242c42 !important; }}
|
| 381 |
+
.af-body h3 {{ color:#e8ecf5 !important; }}
|
| 382 |
+
.af-body p, .af-body div, .af-body li, .af-body span {{ color:#c3ccdf !important; }}
|
| 383 |
+
.af-kpi-cell {{ background:#1a2138 !important; border-color:#2a3350 !important; }}
|
| 384 |
+
.af-kpi-label {{ color:#8fa0c2 !important; }}
|
| 385 |
+
.af-altcard {{ background:#171e33 !important; }}
|
| 386 |
+
.af-footer {{ background:#0e1424 !important; border-color:#242c42 !important; }}
|
| 387 |
+
.af-muted {{ color:#7d8aa8 !important; }}
|
| 388 |
+
a.af-link {{ color:#9fb6ff !important; }}
|
| 389 |
+
}}
|
| 390 |
+
</style>"""
|
| 391 |
+
|
| 392 |
+
|
| 393 |
def _montar_html(
|
| 394 |
report_date: str,
|
| 395 |
metrics: dict[str, Any],
|
|
|
|
| 397 |
resumo_ia: str = "",
|
| 398 |
highlights: dict[str, list[dict[str, Any]]] | None = None,
|
| 399 |
) -> str:
|
| 400 |
+
"""Monta o e-mail executivo completo (documento HTML, pt-BR).
|
| 401 |
+
|
| 402 |
+
Responsivo (empilha em telas pequenas) e com suporte a tema escuro dos
|
| 403 |
+
clientes de e-mail (``prefers-color-scheme``), preservando o layout claro
|
| 404 |
+
como fallback via estilos inline.
|
| 405 |
+
"""
|
| 406 |
altos = sum(1 for a in alerts if a.get("severity") == "high")
|
| 407 |
resumo = (
|
| 408 |
f"Foram gerados <strong>{len(alerts)} alerta(s)</strong>, "
|
|
|
|
| 414 |
if corpo_ia:
|
| 415 |
bloco_ia = (
|
| 416 |
f"<div style='background:{_HOVER};border:1px solid {_DIVISORIA};border-left:4px solid {_PENSO_BLUE};"
|
| 417 |
+
"border-radius:10px;padding:14px 16px;margin:0 0 18px;' class='af-altcard'>"
|
| 418 |
f"<div style='font-size:12px;color:{_PENSO_BLUE};font-weight:700;text-transform:uppercase;"
|
| 419 |
"letter-spacing:.04em;margin-bottom:8px;'>🧠 Análise do dia</div>"
|
| 420 |
f"{corpo_ia}"
|
|
|
|
| 425 |
# este bloco por um <img> com o PNG/SVG oficial (base64 ou hospedado).
|
| 426 |
# Por ora, usa apenas cores da marca (Penso Blue/Black) — sem inventar formas.
|
| 427 |
cabecalho = f"""\
|
| 428 |
+
<td class="af-px af-animate" style="background:linear-gradient(120deg,{_BLACK_INTENSE},{_BLUE_TECH} 55%,{_PENSO_BLUE});padding:26px 28px;">
|
| 429 |
<table role="presentation" cellspacing="0" cellpadding="0"><tr>
|
| 430 |
<td style="width:34px;height:34px;background:#ffffff;border-radius:9px;text-align:center;
|
| 431 |
vertical-align:middle;font-weight:800;font-size:16px;color:{_PENSO_BLUE};">P</td>
|
|
|
|
| 437 |
font-size:12px;font-weight:600;padding:5px 12px;border-radius:999px;">📅 {_escape(report_date)}</span>
|
| 438 |
</td>"""
|
| 439 |
rodape = f"""\
|
| 440 |
+
<td class="af-footer af-px" style="background:{_HOVER};padding:16px 28px;border-top:1px solid {_DIVISORIA};">
|
| 441 |
<table role="presentation" cellspacing="0" cellpadding="0"><tr>
|
| 442 |
<td style="font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em;padding-right:8px;">Fonte de dados</td>
|
| 443 |
<td><img src="{_LOGO_SALESFORCE_URL}" alt="Salesforce" style="height:14px;vertical-align:middle;" /></td>
|
| 444 |
</tr></table>
|
| 445 |
+
<div class="af-muted" style="font-size:12px;color:#94a3b8;margin-top:10px;">
|
| 446 |
Relatório gerado automaticamente pelo agente <strong>Analytical-Force</strong> (Penso).
|
| 447 |
Os números são calculados em Python a partir do Salesforce; a interpretação
|
| 448 |
é gerada por modelo local. O relatório completo está anexado/registrado no sistema.
|
| 449 |
</div>
|
| 450 |
</td>"""
|
| 451 |
return f"""\
|
| 452 |
+
<!DOCTYPE html>
|
| 453 |
+
<html lang="pt-BR">
|
| 454 |
+
<head>
|
| 455 |
+
<meta charset="utf-8">
|
| 456 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 457 |
+
{_estilo_cabecalho()}
|
| 458 |
+
</head>
|
| 459 |
+
<body class="af-wrap" style="margin:0;padding:0;background:#f1f5f9;">
|
| 460 |
+
<div class="af-wrap" style="background:#f1f5f9;padding:24px 0;font-family:Arial,Helvetica,sans-serif;">
|
| 461 |
+
<table role="presentation" width="640" align="center" cellspacing="0" cellpadding="0" class="af-container"
|
| 462 |
+
style="max-width:640px;margin:0 auto;">
|
| 463 |
+
<tr><td class="af-card" style="background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid {_DIVISORIA};">
|
| 464 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
| 465 |
<tr>
|
| 466 |
{cabecalho}
|
| 467 |
</tr>
|
| 468 |
<tr>
|
| 469 |
+
<td class="af-body af-px" style="padding:24px 28px;">
|
| 470 |
<h3 style="font-size:14px;color:#0f172a;margin:0 0 10px;padding-left:10px;border-left:3px solid {_PENSO_BLUE};">Resumo executivo</h3>
|
| 471 |
<p style="font-size:14px;color:#475569;margin:0 0 14px;line-height:1.55;">{resumo}</p>
|
| 472 |
{bloco_ia}
|
|
|
|
| 474 |
<h3 style="font-size:14px;color:#0f172a;margin:22px 0 10px;padding-left:10px;border-left:3px solid {_PENSO_BLUE};">Números-chave</h3>
|
| 475 |
{_grade_kpis(metrics)}
|
| 476 |
|
| 477 |
+
{_bloco_contratos(metrics)}
|
| 478 |
+
|
| 479 |
<h3 style="font-size:14px;color:#0f172a;margin:22px 0 10px;padding-left:10px;border-left:3px solid {_ERRO};">Principais alertas</h3>
|
| 480 |
{_bloco_alertas(alerts)}
|
| 481 |
|
|
|
|
| 487 |
<tr>
|
| 488 |
{rodape}
|
| 489 |
</tr>
|
| 490 |
+
</table>
|
| 491 |
+
</td></tr>
|
| 492 |
</table>
|
| 493 |
+
</div>
|
| 494 |
+
</body>
|
| 495 |
+
</html>"""
|
| 496 |
|
| 497 |
|
| 498 |
def _montar_texto(
|
|
|
|
| 556 |
_ROTULO_DESTAQUE = {
|
| 557 |
"leads_criados": "🆕 Leads criados",
|
| 558 |
"leads_sem_tarefa": "⏳ Leads sem 1ª tarefa",
|
| 559 |
+
"oportunidades_criadas": "✨ Oportunidades criadas",
|
| 560 |
"oportunidades_travadas": "🚧 Oportunidades travadas",
|
| 561 |
"oportunidades_ganhas": "🏆 Oportunidades ganhas",
|
| 562 |
"oportunidades_perdidas": "📉 Oportunidades perdidas",
|
| 563 |
"cancelamentos": "❌ Cancelamentos",
|
| 564 |
+
"satisfacoes_do_dia": "😊 Satisfações do dia",
|
| 565 |
"satisfacoes_piores": "😟 Piores satisfações",
|
| 566 |
+
"contratos_modificados": "📄 Contratos modificados",
|
| 567 |
}
|
| 568 |
|
| 569 |
|
src/models/template_client.py
CHANGED
|
@@ -238,8 +238,54 @@ def _secao_cancelamentos(m: dict[str, Any]) -> list[str]:
|
|
| 238 |
return linhas
|
| 239 |
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
def _secao_alertas(alertas: list[dict[str, Any]]) -> list[str]:
|
| 242 |
-
linhas = ["##
|
| 243 |
if not alertas:
|
| 244 |
linhas.append("Nenhum alerta gerado para o período. ✅")
|
| 245 |
return linhas
|
|
@@ -261,7 +307,7 @@ def _secao_alertas(alertas: list[dict[str, Any]]) -> list[str]:
|
|
| 261 |
|
| 262 |
def _secao_prioridades(alertas: list[dict[str, Any]]) -> list[str]:
|
| 263 |
"""Lista as ações recomendadas priorizadas pela severidade dos alertas."""
|
| 264 |
-
linhas = ["##
|
| 265 |
acoes = [a for a in alertas if a.get("recommended_action")]
|
| 266 |
if not acoes:
|
| 267 |
linhas.append("Sem prioridades críticas. Manter rotina comercial padrão.")
|
|
@@ -282,9 +328,11 @@ def _pipeline_valor(opp: dict[str, Any]) -> Any:
|
|
| 282 |
def _resumo_executivo_template(payload: dict[str, Any]) -> list[str]:
|
| 283 |
"""Resumo executivo em **storytelling**, por regras (sem IA).
|
| 284 |
|
| 285 |
-
Constrói uma narrativa executiva a partir das
|
| 286 |
-
adaptando o tom ao cenário do dia (tranquilo,
|
| 287 |
-
|
|
|
|
|
|
|
| 288 |
"""
|
| 289 |
metrics = payload.get("metrics", {})
|
| 290 |
leads = metrics.get("leads", {}) or {}
|
|
@@ -292,61 +340,92 @@ def _resumo_executivo_template(payload: dict[str, Any]) -> list[str]:
|
|
| 292 |
tasks = metrics.get("tasks", {}) or {}
|
| 293 |
sat = metrics.get("satisfaction", {}) or {}
|
| 294 |
canc = metrics.get("cancellations", {}) or {}
|
|
|
|
| 295 |
alertas = payload.get("alerts", []) or []
|
| 296 |
altos = sum(1 for a in alertas if a.get("severity") == "high")
|
| 297 |
data = payload.get("report_date", "o dia")
|
| 298 |
|
| 299 |
-
#
|
| 300 |
if altos == 0:
|
| 301 |
abertura = (
|
| 302 |
-
f"O dia **{data}**
|
| 303 |
-
"
|
| 304 |
)
|
| 305 |
elif altos <= 2:
|
| 306 |
abertura = (
|
| 307 |
-
f"O dia **{data}**
|
| 308 |
-
"que,
|
|
|
|
| 309 |
)
|
| 310 |
else:
|
| 311 |
abertura = (
|
| 312 |
-
f"O dia **{data}**
|
| 313 |
-
"
|
|
|
|
| 314 |
)
|
| 315 |
|
| 316 |
-
# Capítulo Leads.
|
| 317 |
novos = _num(leads.get("new_leads"))
|
| 318 |
conv = _pct(leads.get("conversion_rate"))
|
| 319 |
sem_tarefa = int(leads.get("leads_without_first_task") or 0)
|
| 320 |
cap_leads = (
|
| 321 |
-
f"
|
| 322 |
-
f"**{conv}**{_variacao(leads, 'conversion_rate')}."
|
| 323 |
)
|
| 324 |
if sem_tarefa:
|
| 325 |
cap_leads += (
|
| 326 |
-
f"
|
| 327 |
-
"ainda
|
|
|
|
| 328 |
)
|
| 329 |
|
| 330 |
-
# Capítulo Oportunidades.
|
| 331 |
ganhas = _num(opp.get("won_opportunities"))
|
| 332 |
perdidas = _num(opp.get("lost_opportunities"))
|
|
|
|
| 333 |
pipeline = _moeda(_pipeline_valor(opp))
|
| 334 |
paradas = int(opp.get("stalled_opportunities") or 0)
|
| 335 |
alto_valor = int(opp.get("high_value_stalled_opportunities") or 0)
|
| 336 |
cap_opp = (
|
| 337 |
-
f"No
|
| 338 |
-
f"{_variacao(opp, 'open_pipeline_amount')}
|
| 339 |
-
f"**{
|
|
|
|
| 340 |
)
|
| 341 |
if alto_valor:
|
| 342 |
cap_opp += (
|
| 343 |
-
f"
|
| 344 |
-
"
|
|
|
|
| 345 |
)
|
| 346 |
elif paradas:
|
| 347 |
cap_opp += f" Ainda há **{paradas} oportunidade(s) parada(s)** aguardando reengajamento."
|
| 348 |
|
| 349 |
-
# Capítulo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
venc = int(tasks.get("tasks_overdue") or 0)
|
| 351 |
cap_ops = ""
|
| 352 |
if venc:
|
|
@@ -355,12 +434,13 @@ def _resumo_executivo_template(payload: dict[str, Any]) -> list[str]:
|
|
| 355 |
"deve recair sobre as ligadas a negócios de maior valor."
|
| 356 |
)
|
| 357 |
|
| 358 |
-
# Capítulo cliente (satisfação/cancelamento)
|
| 359 |
cap_cliente = ""
|
| 360 |
if sat.get("configured") and sat.get("responses"):
|
| 361 |
cap_cliente += (
|
| 362 |
-
f"Do lado do cliente, a satisfação
|
| 363 |
-
f"
|
|
|
|
| 364 |
)
|
| 365 |
if canc.get("configured") and canc.get("cancellations_count"):
|
| 366 |
cap_cliente += (
|
|
@@ -368,20 +448,24 @@ def _resumo_executivo_template(payload: dict[str, Any]) -> list[str]:
|
|
| 368 |
f"**{_moeda(canc.get('mrr_impact'))}** em MRR — atenção à retenção."
|
| 369 |
)
|
| 370 |
|
| 371 |
-
# Fecho com direção.
|
| 372 |
if altos:
|
| 373 |
titulos = "; ".join(a.get("title", "") for a in alertas if a.get("severity") == "high")
|
| 374 |
fecho = (
|
| 375 |
-
f"**
|
| 376 |
-
"na seção de Prioridades
|
|
|
|
| 377 |
)
|
| 378 |
else:
|
| 379 |
fecho = (
|
| 380 |
-
"**
|
| 381 |
-
"antecipar follow-ups dos
|
|
|
|
| 382 |
)
|
| 383 |
|
| 384 |
paragrafos = [abertura, cap_leads, cap_opp]
|
|
|
|
|
|
|
| 385 |
if cap_ops:
|
| 386 |
paragrafos.append(cap_ops)
|
| 387 |
if cap_cliente:
|
|
@@ -434,7 +518,7 @@ def _conclusao_template(payload: dict[str, Any]) -> list[str]:
|
|
| 434 |
"""Conclusão objetiva, sempre ligada a métricas/alertas."""
|
| 435 |
alertas = payload.get("alerts", []) or []
|
| 436 |
altos = [a for a in alertas if a.get("severity") == "high"]
|
| 437 |
-
linhas = ["##
|
| 438 |
if altos:
|
| 439 |
titulos = "; ".join(a.get("title", "") for a in altos[:3])
|
| 440 |
linhas.append(
|
|
@@ -495,8 +579,10 @@ def renderizar_relatorio(
|
|
| 495 |
linhas.append("")
|
| 496 |
linhas += _secao_cancelamentos(metrics.get("cancellations", {}) or {})
|
| 497 |
linhas.append("")
|
|
|
|
|
|
|
| 498 |
|
| 499 |
-
#
|
| 500 |
linhas += _secao_alertas(alertas)
|
| 501 |
linhas.append("")
|
| 502 |
linhas += _secao_prioridades(alertas)
|
|
|
|
| 238 |
return linhas
|
| 239 |
|
| 240 |
|
| 241 |
+
def _secao_contratos(m: dict[str, Any]) -> list[str]:
|
| 242 |
+
"""Seção de Contratos: modificações do dia + reajuste do mês (se configurado)."""
|
| 243 |
+
linhas = ["## 7. Contratos", ""]
|
| 244 |
+
if not m or not m.get("configured"):
|
| 245 |
+
linhas.append("> ⚙️ Fonte de contratos ainda não configurada.")
|
| 246 |
+
return linhas
|
| 247 |
+
linhas.append(_linha("Contratos modificados hoje", _num(m.get("modified_today_count"))))
|
| 248 |
+
modificados = m.get("modified_today") or []
|
| 249 |
+
if modificados:
|
| 250 |
+
linhas.append("")
|
| 251 |
+
linhas.append("**Quem modificou o quê hoje:**")
|
| 252 |
+
for c in modificados[:10]:
|
| 253 |
+
quem = c.get("modified_by") or "responsável não identificado"
|
| 254 |
+
linhas.append(f"- {c.get('name', c.get('id', '—'))} — modificado por {quem}")
|
| 255 |
+
if not m.get("readjustment_configured"):
|
| 256 |
+
linhas.append("")
|
| 257 |
+
linhas.append(
|
| 258 |
+
"> ⚙️ Módulo de reajuste de contrato não configurado "
|
| 259 |
+
"(defina os campos de valor atual/anterior/data de reajuste)."
|
| 260 |
+
)
|
| 261 |
+
return linhas
|
| 262 |
+
linhas += [
|
| 263 |
+
"",
|
| 264 |
+
_linha("Contratos reajustados no mês", _num(m.get("readjustment_month_count"))),
|
| 265 |
+
_linha("Total de reajuste aplicado no mês", _moeda(m.get("readjustment_month_total"))),
|
| 266 |
+
_linha("Reajuste médio (%)", _pct(m.get("readjustment_month_avg_percent"))),
|
| 267 |
+
]
|
| 268 |
+
inconsistentes = int(m.get("readjustment_inconsistent_count") or 0)
|
| 269 |
+
if inconsistentes:
|
| 270 |
+
linhas.append(
|
| 271 |
+
_linha("Contratos com possível inconsistência", _num(inconsistentes))
|
| 272 |
+
)
|
| 273 |
+
linhas.append("")
|
| 274 |
+
linhas.append(f"> ⚠️ {m.get('readjustment_disclaimer', '')}")
|
| 275 |
+
qtd = int(m.get("readjustment_month_count") or 0)
|
| 276 |
+
if qtd:
|
| 277 |
+
linhas.append(_insight(
|
| 278 |
+
f"{qtd} contrato(s) reajustado(s) no mês somam "
|
| 279 |
+
f"{_moeda(m.get('readjustment_month_total'))} — confirme os "
|
| 280 |
+
f"{inconsistentes} caso(s) sinalizado(s) com o time de contratos antes de reportar como definitivo."
|
| 281 |
+
if inconsistentes
|
| 282 |
+
else f"{qtd} contrato(s) reajustado(s) no mês somam {_moeda(m.get('readjustment_month_total'))}."
|
| 283 |
+
))
|
| 284 |
+
return linhas
|
| 285 |
+
|
| 286 |
+
|
| 287 |
def _secao_alertas(alertas: list[dict[str, Any]]) -> list[str]:
|
| 288 |
+
linhas = ["## 8. Principais Alertas", ""]
|
| 289 |
if not alertas:
|
| 290 |
linhas.append("Nenhum alerta gerado para o período. ✅")
|
| 291 |
return linhas
|
|
|
|
| 307 |
|
| 308 |
def _secao_prioridades(alertas: list[dict[str, Any]]) -> list[str]:
|
| 309 |
"""Lista as ações recomendadas priorizadas pela severidade dos alertas."""
|
| 310 |
+
linhas = ["## 9. Prioridades para Hoje", ""]
|
| 311 |
acoes = [a for a in alertas if a.get("recommended_action")]
|
| 312 |
if not acoes:
|
| 313 |
linhas.append("Sem prioridades críticas. Manter rotina comercial padrão.")
|
|
|
|
| 328 |
def _resumo_executivo_template(payload: dict[str, Any]) -> list[str]:
|
| 329 |
"""Resumo executivo em **storytelling**, por regras (sem IA).
|
| 330 |
|
| 331 |
+
Constrói uma narrativa executiva — com começo, meio e fim — a partir das
|
| 332 |
+
métricas já calculadas, adaptando o tom ao cenário do dia (tranquilo,
|
| 333 |
+
atenção ou crítico) e usando os números como PROVA para convencer a
|
| 334 |
+
liderança a agir. Nenhum número é inventado: cada frase referencia um
|
| 335 |
+
valor presente no JSON de métricas.
|
| 336 |
"""
|
| 337 |
metrics = payload.get("metrics", {})
|
| 338 |
leads = metrics.get("leads", {}) or {}
|
|
|
|
| 340 |
tasks = metrics.get("tasks", {}) or {}
|
| 341 |
sat = metrics.get("satisfaction", {}) or {}
|
| 342 |
canc = metrics.get("cancellations", {}) or {}
|
| 343 |
+
contratos = metrics.get("contracts", {}) or {}
|
| 344 |
alertas = payload.get("alerts", []) or []
|
| 345 |
altos = sum(1 for a in alertas if a.get("severity") == "high")
|
| 346 |
data = payload.get("report_date", "o dia")
|
| 347 |
|
| 348 |
+
# Abertura: o "gancho" da história, com o tom do dia como pano de fundo.
|
| 349 |
if altos == 0:
|
| 350 |
abertura = (
|
| 351 |
+
f"O dia **{data}** conta uma história de controle: nenhum risco alto "
|
| 352 |
+
"surgiu na operação comercial, e os números abaixo mostram por quê."
|
| 353 |
)
|
| 354 |
elif altos <= 2:
|
| 355 |
abertura = (
|
| 356 |
+
f"O dia **{data}** tem um ponto de virada: **{altos} risco(s) alto(s)** "
|
| 357 |
+
"aparecem no meio de um funil que, no restante, segue saudável — e é "
|
| 358 |
+
"exatamente aí que a atenção de hoje faz diferença."
|
| 359 |
)
|
| 360 |
else:
|
| 361 |
abertura = (
|
| 362 |
+
f"O dia **{data}** é de sinal vermelho: **{altos} riscos altos** se "
|
| 363 |
+
"acumulam ao mesmo tempo, e os dados a seguir mostram onde o pipeline "
|
| 364 |
+
"e a receita podem ser afetados se ninguém agir agora."
|
| 365 |
)
|
| 366 |
|
| 367 |
+
# Capítulo Leads — entrada do funil.
|
| 368 |
novos = _num(leads.get("new_leads"))
|
| 369 |
conv = _pct(leads.get("conversion_rate"))
|
| 370 |
sem_tarefa = int(leads.get("leads_without_first_task") or 0)
|
| 371 |
cap_leads = (
|
| 372 |
+
f"Tudo começa na entrada do funil: **{novos} novo(s) lead(s)** chegaram "
|
| 373 |
+
f"hoje, convertendo a **{conv}**{_variacao(leads, 'conversion_rate')}."
|
| 374 |
)
|
| 375 |
if sem_tarefa:
|
| 376 |
cap_leads += (
|
| 377 |
+
f" O detalhe que muda o resultado de amanhã: **{sem_tarefa} lead(s)** "
|
| 378 |
+
"ainda não têm a primeira tarefa registrada — é o ganho mais barato do "
|
| 379 |
+
"dia, e cada hora sem follow-up reduz a chance de conversão."
|
| 380 |
)
|
| 381 |
|
| 382 |
+
# Capítulo Oportunidades — onde a receita se decide.
|
| 383 |
ganhas = _num(opp.get("won_opportunities"))
|
| 384 |
perdidas = _num(opp.get("lost_opportunities"))
|
| 385 |
+
criadas = int(opp.get("new_opportunities") or 0)
|
| 386 |
pipeline = _moeda(_pipeline_valor(opp))
|
| 387 |
paradas = int(opp.get("stalled_opportunities") or 0)
|
| 388 |
alto_valor = int(opp.get("high_value_stalled_opportunities") or 0)
|
| 389 |
cap_opp = (
|
| 390 |
+
f"No meio da história está o pipeline: **{_moeda(_pipeline_valor(opp))}** em "
|
| 391 |
+
f"aberto{_variacao(opp, 'open_pipeline_amount')}"
|
| 392 |
+
+ (f", reforçado por **{criadas} oportunidade(s) nova(s)**" if criadas else "")
|
| 393 |
+
+ f", com **{ganhas} ganha(s)** contra **{perdidas} perdida(s)** no fechamento de hoje."
|
| 394 |
)
|
| 395 |
if alto_valor:
|
| 396 |
cap_opp += (
|
| 397 |
+
f" A prova mais concreta do risco: **{alto_valor} oportunidade(s) de "
|
| 398 |
+
"alto valor parada(s)** — negócios relevantes esfriando sem contato, o "
|
| 399 |
+
"ponto que mais pesa contra o resultado do mês."
|
| 400 |
)
|
| 401 |
elif paradas:
|
| 402 |
cap_opp += f" Ainda há **{paradas} oportunidade(s) parada(s)** aguardando reengajamento."
|
| 403 |
|
| 404 |
+
# Capítulo Contratos — reajuste e movimentação, com a ressalva de qualidade.
|
| 405 |
+
cap_contratos = ""
|
| 406 |
+
if contratos.get("configured"):
|
| 407 |
+
mod = int(contratos.get("modified_today_count") or 0)
|
| 408 |
+
if mod:
|
| 409 |
+
cap_contratos = f"Nos contratos, **{mod} registro(s)** foram modificados hoje"
|
| 410 |
+
responsaveis = {
|
| 411 |
+
c.get("modified_by")
|
| 412 |
+
for c in (contratos.get("modified_today") or [])
|
| 413 |
+
if c.get("modified_by")
|
| 414 |
+
}
|
| 415 |
+
if responsaveis:
|
| 416 |
+
cap_contratos += f", movimentados por {', '.join(sorted(responsaveis)[:3])}"
|
| 417 |
+
cap_contratos += "."
|
| 418 |
+
if contratos.get("readjustment_configured") and contratos.get("readjustment_month_count"):
|
| 419 |
+
qtd_reaj = _num(contratos.get("readjustment_month_count"))
|
| 420 |
+
total_reaj = _moeda(contratos.get("readjustment_month_total"))
|
| 421 |
+
cap_contratos += (
|
| 422 |
+
f" No acumulado do mês, **{qtd_reaj} contrato(s)** já foram "
|
| 423 |
+
f"reajustados, somando **{total_reaj}** — número estimado pela "
|
| 424 |
+
"diferença entre valor atual e anterior, que pode não ser 100% "
|
| 425 |
+
"exato caso haja erro de preenchimento no contrato."
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
# Capítulo operação (tarefas) — sem alarmismo, só o fato.
|
| 429 |
venc = int(tasks.get("tasks_overdue") or 0)
|
| 430 |
cap_ops = ""
|
| 431 |
if venc:
|
|
|
|
| 434 |
"deve recair sobre as ligadas a negócios de maior valor."
|
| 435 |
)
|
| 436 |
|
| 437 |
+
# Capítulo cliente (satisfação/cancelamento) — o desfecho do relacionamento.
|
| 438 |
cap_cliente = ""
|
| 439 |
if sat.get("configured") and sat.get("responses"):
|
| 440 |
cap_cliente += (
|
| 441 |
+
f"Do lado do cliente, a satisfação do dia ficou em **{_num(sat.get('avg_score'))}** "
|
| 442 |
+
f"({_num(sat.get('responses'))} resposta(s)), com "
|
| 443 |
+
f"**{_num(sat.get('negative_count'))} avaliação(ões) negativa(s)**."
|
| 444 |
)
|
| 445 |
if canc.get("configured") and canc.get("cancellations_count"):
|
| 446 |
cap_cliente += (
|
|
|
|
| 448 |
f"**{_moeda(canc.get('mrr_impact'))}** em MRR — atenção à retenção."
|
| 449 |
)
|
| 450 |
|
| 451 |
+
# Fecho: a moral da história, com direção clara de ação.
|
| 452 |
if altos:
|
| 453 |
titulos = "; ".join(a.get("title", "") for a in alertas if a.get("severity") == "high")
|
| 454 |
fecho = (
|
| 455 |
+
f"**A conclusão é direta:** os dados apontam para {titulos.lower()} como "
|
| 456 |
+
"prioridade — as ações detalhadas estão na seção de Prioridades, e adiar "
|
| 457 |
+
"significa deixar esse risco crescer amanhã."
|
| 458 |
)
|
| 459 |
else:
|
| 460 |
fecho = (
|
| 461 |
+
"**A conclusão é direta:** os números sustentam manter a cadência atual, "
|
| 462 |
+
"acompanhar as variações sinalizadas e antecipar os follow-ups dos "
|
| 463 |
+
"negócios de maior valor antes que virem urgência."
|
| 464 |
)
|
| 465 |
|
| 466 |
paragrafos = [abertura, cap_leads, cap_opp]
|
| 467 |
+
if cap_contratos:
|
| 468 |
+
paragrafos.append(cap_contratos.strip())
|
| 469 |
if cap_ops:
|
| 470 |
paragrafos.append(cap_ops)
|
| 471 |
if cap_cliente:
|
|
|
|
| 518 |
"""Conclusão objetiva, sempre ligada a métricas/alertas."""
|
| 519 |
alertas = payload.get("alerts", []) or []
|
| 520 |
altos = [a for a in alertas if a.get("severity") == "high"]
|
| 521 |
+
linhas = ["## 10. Conclusão", ""]
|
| 522 |
if altos:
|
| 523 |
titulos = "; ".join(a.get("title", "") for a in altos[:3])
|
| 524 |
linhas.append(
|
|
|
|
| 579 |
linhas.append("")
|
| 580 |
linhas += _secao_cancelamentos(metrics.get("cancellations", {}) or {})
|
| 581 |
linhas.append("")
|
| 582 |
+
linhas += _secao_contratos(metrics.get("contracts", {}) or {})
|
| 583 |
+
linhas.append("")
|
| 584 |
|
| 585 |
+
# 8-10. Alertas, prioridades e conclusão.
|
| 586 |
linhas += _secao_alertas(alertas)
|
| 587 |
linhas.append("")
|
| 588 |
linhas += _secao_prioridades(alertas)
|
src/salesforce/extractors.py
CHANGED
|
@@ -408,6 +408,119 @@ class SalesforceExtractor:
|
|
| 408 |
logger.info("Cancelamentos extraídos: %d.", len(df))
|
| 409 |
return df
|
| 410 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
|
| 412 |
# ----------------------------------------------------------------------
|
| 413 |
# Conversão de sentimento (categórico) em nota numérica
|
|
@@ -454,6 +567,16 @@ _COLUNAS_TASK = [
|
|
| 454 |
"IsClosed",
|
| 455 |
]
|
| 456 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
|
| 458 |
def _colunas_lead(fm: FieldMapping) -> list[str]:
|
| 459 |
"""Colunas esperadas de Lead, incluindo customizados."""
|
|
|
|
| 408 |
logger.info("Cancelamentos extraídos: %d.", len(df))
|
| 409 |
return df
|
| 410 |
|
| 411 |
+
# ------------------------------------------------------------------
|
| 412 |
+
# Extração — Contratos (modificações do dia + reajuste do mês)
|
| 413 |
+
# ------------------------------------------------------------------
|
| 414 |
+
def _campos_contrato(self, source: dict[str, Any], *extras: str) -> list[str]:
|
| 415 |
+
"""Monta a lista de campos de contrato: base + opcionais configurados.
|
| 416 |
+
|
| 417 |
+
A base usa SOMENTE campos padrão do Salesforce (sempre disponíveis em
|
| 418 |
+
qualquer objeto), garantindo que "contratos modificados" funcione sem
|
| 419 |
+
exigir configuração alguma (regra 3: nunca inventar campos).
|
| 420 |
+
"""
|
| 421 |
+
campos = list(_COLUNAS_CONTRATO_BASE)
|
| 422 |
+
for campo in (source.get("account_name_field"), *extras):
|
| 423 |
+
if campo and campo not in campos:
|
| 424 |
+
campos.append(campo)
|
| 425 |
+
return campos
|
| 426 |
+
|
| 427 |
+
def extrair_contratos_modificados(
|
| 428 |
+
self, dia: date, source: dict[str, Any]
|
| 429 |
+
) -> pd.DataFrame:
|
| 430 |
+
"""Extrai contratos modificados no dia (por ``LastModifiedDate``).
|
| 431 |
+
|
| 432 |
+
Usa apenas campos padrão (Id, Name, LastModifiedDate, LastModifiedBy)
|
| 433 |
+
— sempre disponíveis, sem exigir configuração. Se ``value_field``/
|
| 434 |
+
``previous_value_field`` estiverem configurados, também são incluídos
|
| 435 |
+
(para o e-mail poder mostrar o valor atual do contrato modificado).
|
| 436 |
+
|
| 437 |
+
Args:
|
| 438 |
+
dia: Dia de referência.
|
| 439 |
+
source: Configuração ``contract_source``.
|
| 440 |
+
|
| 441 |
+
Returns:
|
| 442 |
+
DataFrame com os contratos modificados no dia (vazio se o objeto
|
| 443 |
+
de contrato não estiver configurado ou a consulta falhar).
|
| 444 |
+
"""
|
| 445 |
+
objeto = source.get("object")
|
| 446 |
+
if not objeto:
|
| 447 |
+
return pd.DataFrame(columns=_COLUNAS_CONTRATO_BASE)
|
| 448 |
+
|
| 449 |
+
inicio, fim = intervalo_do_dia(dia, self._tz)
|
| 450 |
+
campos = self._campos_contrato(
|
| 451 |
+
source, source.get("value_field", ""), source.get("previous_value_field", "")
|
| 452 |
+
)
|
| 453 |
+
campos_ok = self._campos_validos(objeto, campos)
|
| 454 |
+
if "LastModifiedDate" not in campos_ok:
|
| 455 |
+
logger.warning("LastModifiedDate ausente em %s; contratos ignorados.", objeto)
|
| 456 |
+
return pd.DataFrame(columns=campos_ok)
|
| 457 |
+
|
| 458 |
+
soql = queries.registros_por_intervalo(
|
| 459 |
+
objeto, campos_ok, "LastModifiedDate",
|
| 460 |
+
para_soql_datetime(inicio), para_soql_datetime(fim),
|
| 461 |
+
)
|
| 462 |
+
try:
|
| 463 |
+
registros = self._client.query(soql)
|
| 464 |
+
except Exception as exc: # fonte opcional não deve quebrar o agente
|
| 465 |
+
logger.warning("Falha ao extrair contratos modificados: %s", type(exc).__name__)
|
| 466 |
+
return pd.DataFrame(columns=campos_ok)
|
| 467 |
+
logger.info("Contratos modificados extraídos: %d.", len(registros))
|
| 468 |
+
return self._para_dataframe(registros, campos_ok)
|
| 469 |
+
|
| 470 |
+
def extrair_contratos_reajustados_mes(
|
| 471 |
+
self, dia: date, source: dict[str, Any]
|
| 472 |
+
) -> pd.DataFrame:
|
| 473 |
+
"""Extrai contratos reajustados no MÊS de referência (valor atual x anterior).
|
| 474 |
+
|
| 475 |
+
Módulo configurável (regra 16/17): requer ``value_field``,
|
| 476 |
+
``previous_value_field`` e ``readjustment_date_field`` configurados em
|
| 477 |
+
``contract_source``. Sem essa configuração, retorna DataFrame vazio —
|
| 478 |
+
a análise de reajuste permanece desativada, sem quebrar o pipeline.
|
| 479 |
+
|
| 480 |
+
Args:
|
| 481 |
+
dia: Dia de referência (usado para determinar o mês corrente).
|
| 482 |
+
source: Configuração ``contract_source``.
|
| 483 |
+
|
| 484 |
+
Returns:
|
| 485 |
+
DataFrame com os contratos reajustados no mês (vazio se
|
| 486 |
+
desativado ou a consulta falhar).
|
| 487 |
+
"""
|
| 488 |
+
objeto = source.get("object")
|
| 489 |
+
date_field = source.get("readjustment_date_field")
|
| 490 |
+
value_field = source.get("value_field")
|
| 491 |
+
previous_field = source.get("previous_value_field")
|
| 492 |
+
if not (objeto and date_field and value_field and previous_field):
|
| 493 |
+
return pd.DataFrame(columns=_COLUNAS_CONTRATO_BASE)
|
| 494 |
+
|
| 495 |
+
inicio_mes = dia.replace(day=1)
|
| 496 |
+
fim_mes = (
|
| 497 |
+
inicio_mes.replace(year=inicio_mes.year + 1, month=1)
|
| 498 |
+
if inicio_mes.month == 12
|
| 499 |
+
else inicio_mes.replace(month=inicio_mes.month + 1)
|
| 500 |
+
)
|
| 501 |
+
campos = self._campos_contrato(
|
| 502 |
+
source, value_field, previous_field, source.get("readjustment_field", ""), date_field
|
| 503 |
+
)
|
| 504 |
+
campos_ok = self._campos_validos(objeto, campos)
|
| 505 |
+
if date_field not in campos_ok:
|
| 506 |
+
logger.warning(
|
| 507 |
+
"Campo de data de reajuste (%s) ausente em %s; módulo desativado.",
|
| 508 |
+
date_field, objeto,
|
| 509 |
+
)
|
| 510 |
+
return pd.DataFrame(columns=campos_ok)
|
| 511 |
+
|
| 512 |
+
soql = queries.registros_por_intervalo(
|
| 513 |
+
objeto, campos_ok, date_field,
|
| 514 |
+
para_soql_date(inicio_mes), para_soql_date(fim_mes),
|
| 515 |
+
)
|
| 516 |
+
try:
|
| 517 |
+
registros = self._client.query(soql)
|
| 518 |
+
except Exception as exc:
|
| 519 |
+
logger.warning("Falha ao extrair reajustes de contrato: %s", type(exc).__name__)
|
| 520 |
+
return pd.DataFrame(columns=campos_ok)
|
| 521 |
+
logger.info("Contratos reajustados no mês extraídos: %d.", len(registros))
|
| 522 |
+
return self._para_dataframe(registros, campos_ok)
|
| 523 |
+
|
| 524 |
|
| 525 |
# ----------------------------------------------------------------------
|
| 526 |
# Conversão de sentimento (categórico) em nota numérica
|
|
|
|
| 567 |
"IsClosed",
|
| 568 |
]
|
| 569 |
|
| 570 |
+
# Campos padrão de Contrato (sempre presentes, independente de configuração).
|
| 571 |
+
_COLUNAS_CONTRATO_BASE = [
|
| 572 |
+
"Id",
|
| 573 |
+
"Name",
|
| 574 |
+
"CreatedDate",
|
| 575 |
+
"LastModifiedDate",
|
| 576 |
+
"LastModifiedById",
|
| 577 |
+
"LastModifiedBy.Name",
|
| 578 |
+
]
|
| 579 |
+
|
| 580 |
|
| 581 |
def _colunas_lead(fm: FieldMapping) -> list[str]:
|
| 582 |
"""Colunas esperadas de Lead, incluindo customizados."""
|
src/salesforce/queries.py
CHANGED
|
@@ -196,3 +196,31 @@ def registros_por_data(
|
|
| 196 |
"""
|
| 197 |
cols = _campos(campos)
|
| 198 |
return f"SELECT {cols} FROM {objeto} WHERE {date_field} = {dia_iso}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
"""
|
| 197 |
cols = _campos(campos)
|
| 198 |
return f"SELECT {cols} FROM {objeto} WHERE {date_field} = {dia_iso}"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def registros_por_intervalo(
|
| 202 |
+
objeto: str, campos: list[str], date_field: str, inicio: str, fim: str
|
| 203 |
+
) -> str:
|
| 204 |
+
"""Monta uma consulta por intervalo [inicio, fim) em um campo configurável.
|
| 205 |
+
|
| 206 |
+
Diferente de :func:`registros_por_data` (igualdade exata), esta função
|
| 207 |
+
aceita um INTERVALO — necessário para campos ``datetime`` (ex.:
|
| 208 |
+
``LastModifiedDate``, para "contratos modificados no dia") ou para
|
| 209 |
+
períodos maiores que um dia (ex.: "reajustes aplicados no mês"). Os
|
| 210 |
+
limites já devem vir formatados para SOQL (ver ``utils.date_utils``).
|
| 211 |
+
|
| 212 |
+
Args:
|
| 213 |
+
objeto: Nome da API do objeto (ex.: ``Contrato_oPT__c``).
|
| 214 |
+
campos: Lista de campos a selecionar.
|
| 215 |
+
date_field: Campo de data/datetime usado no filtro.
|
| 216 |
+
inicio: Limite inferior (inclusive), já formatado para SOQL.
|
| 217 |
+
fim: Limite superior (exclusivo), já formatado para SOQL.
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
String SOQL pronta (``SELECT ... WHERE date_field >= inicio AND date_field < fim``).
|
| 221 |
+
"""
|
| 222 |
+
cols = _campos(campos)
|
| 223 |
+
return (
|
| 224 |
+
f"SELECT {cols} FROM {objeto} "
|
| 225 |
+
f"WHERE {date_field} >= {inicio} AND {date_field} < {fim}"
|
| 226 |
+
)
|