ViniciusKhan commited on
Commit
463cf95
·
1 Parent(s): bbc30f3

Contratos/reajuste, storytelling, e-mail responsivo/dark mode, dashboard

Browse files
.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 interpretativo e acionável.\n"
 
30
  "Regras inegociáveis:\n"
31
  "1. NUNCA recalcule, invente ou altere números. Use apenas os valores do JSON.\n"
32
- "2. Separe claramente: fatos, diagnóstico, riscos e ações recomendadas.\n"
33
- "3. Toda conclusão deve estar ligada a uma métrica ou alerta presente no JSON.\n"
34
- "4. Seja direto e técnico. Não use saudações nem linguagem genérica.\n"
35
- "5. Produza no máximo 3 parágrafos curtos. Não repita a tabela de métricas."
 
 
 
 
 
 
 
 
 
 
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, esta fonte não filtra por data —
561
- ela alimenta 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,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 corpo HTML completo do e-mail executivo (pt-BR)."""
 
 
 
 
 
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
- <div style="background:#f1f5f9;padding:24px 0;font-family:Arial,Helvetica,sans-serif;">
320
- <table role="presentation" width="640" align="center" cellspacing="0" cellpadding="0"
321
- style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;
322
- overflow:hidden;border:1px solid {_DIVISORIA};">
 
 
 
 
 
 
 
 
 
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 = ["## 7. Principais Alertas", ""]
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 = ["## 8. Prioridades para Hoje", ""]
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 métricas calculadas,
286
- adaptando o tom ao cenário do dia (tranquilo, atenção ou crítico) e
287
- destacando os pontos que exigem ação. Nenhum número é inventado.
 
 
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
- # Tom de abertura conforme o cenário.
300
  if altos == 0:
301
  abertura = (
302
- f"O dia **{data}** transcorreu sob controle: a operação comercial não "
303
- "acumulou riscos altos e segue dentro do ritmo esperado."
304
  )
305
  elif altos <= 2:
306
  abertura = (
307
- f"O dia **{data}** pede atenção pontual: surgiram **{altos} risco(s) alto(s)** "
308
- "que, se tratados hoje, evitam impacto no funil."
 
309
  )
310
  else:
311
  abertura = (
312
- f"O dia **{data}** exige ação imediata: são **{altos} riscos altos** "
313
- "concentrados que podem comprometer pipeline e receita se não forem endereçados."
 
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"Na entrada do funil, **{novos} novo(s) lead(s)** chegaram com conversão de "
322
- f"**{conv}**{_variacao(leads, 'conversion_rate')}."
323
  )
324
  if sem_tarefa:
325
  cap_leads += (
326
- f" **{sem_tarefa} lead(s) sem a primeira tarefa**, ou seja, contatos novos "
327
- "ainda sem follow-up — o ponto mais barato de corrigir agora."
 
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 pipeline, o valor em aberto soma **{pipeline}**"
338
- f"{_variacao(opp, 'open_pipeline_amount')}, com **{ganhas} ganha(s)** e "
339
- f"**{perdidas} perdida(s)** fechando no dia."
 
340
  )
341
  if alto_valor:
342
  cap_opp += (
343
- f" O sinal mais sensível: **{alto_valor} oportunidade(s) de alto valor parada(s)** — "
344
- "negócios relevantes que estão esfriando e merecem contato prioritário."
 
345
  )
346
  elif paradas:
347
  cap_opp += f" Ainda há **{paradas} oportunidade(s) parada(s)** aguardando reengajamento."
348
 
349
- # Capítulo operação (tarefas) sem alarmismo.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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), se configurado.
359
  cap_cliente = ""
360
  if sat.get("configured") and sat.get("responses"):
361
  cap_cliente += (
362
- f"Do lado do cliente, a satisfação média ficou em **{_num(sat.get('avg_score'))}** "
363
- f"com **{_num(sat.get('negative_count'))} avaliação(ões) negativa(s)**."
 
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"**Direção para hoje:** priorizar {titulos.lower()}. As ações detalhadas estão "
376
- "na seção de Prioridades."
 
377
  )
378
  else:
379
  fecho = (
380
- "**Direção para hoje:** manter a cadência, acompanhar as variações sinalizadas e "
381
- "antecipar follow-ups dos negócios de maior valor."
 
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 = ["## 9. Conclusão", ""]
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
- # 7-9. Alertas, prioridades e conclusão.
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 Contratosreajuste 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
+ )