ViniciusKhan commited on
Commit
bbc30f3
·
1 Parent(s): 197ac2a

Corrige e-mail, adiciona CC persistido e migra painel para React

Browse files
.env.example CHANGED
@@ -172,3 +172,19 @@ SF_CANC_MRR_FIELD=Valor_Liquido_Mensal__c
172
  SF_CANC_REASON_FIELD=Motivo_Cancelamento__c
173
  SF_CANC_PRODUCT_FIELD=ProdutoPrincipal__c
174
  SF_CANC_OWNER_FIELD=GC_Nome__c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  SF_CANC_REASON_FIELD=Motivo_Cancelamento__c
173
  SF_CANC_PRODUCT_FIELD=ProdutoPrincipal__c
174
  SF_CANC_OWNER_FIELD=GC_Nome__c
175
+
176
+ # ---- Fonte de Contratos (módulo de CONSULTA — busca somente leitura) ----
177
+ # Objetos usados pela tela de "Busca" do painel (contratos, itens de contrato
178
+ # e clientes). NÃO participa do pipeline diário de métricas — é lido sob
179
+ # demanda (busca ao vivo no Salesforce) e cacheado no Turso (busca híbrida).
180
+ SF_CONTRACT_OBJECT=Contrato_oPT__c
181
+ SF_CONTRACT_ITEM_OBJECT=Item_do_Contrato__c
182
+ # Nome de API do campo de LOOKUP do Contrato -> Account (ex.: "Conta__c").
183
+ # Deixe em branco se não souber: a busca de contratos por cliente fica
184
+ # desativada até você preencher (consulte Setup -> Gerenciador de Objetos ->
185
+ # Contrato_oPT__c -> Campos, e copie o "Nome de API" do campo de lookup).
186
+ SF_CONTRACT_ACCOUNT_FIELD=
187
+ # Nome de API do campo de LOOKUP do Item de Contrato -> Contrato (ex.:
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=
api.py CHANGED
@@ -123,6 +123,8 @@ relatório). Opera <strong>somente leitura</strong> no Salesforce.</p>
123
  <li><code>POST /run</code> — executa o pipeline (corpo JSON)</li>
124
  <li><code>GET /run?date=YYYY-MM-DD</code> — executa pelo navegador (mais fácil)</li>
125
  <li><code>GET /metrics/{data}</code> — lê as métricas já salvas (rápido, sem reexecutar)</li>
 
 
126
  <li><a href="/docs">/docs</a> — documentação interativa (Swagger)</li>
127
  </ul>
128
  <p>Se <code>APP_API_TOKEN</code> estiver definido, envie o cabeçalho
@@ -433,6 +435,90 @@ def metrics_do_dia(
433
  )
434
 
435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  # ----------------------------------------------------------------------
437
  # Painel React (estático) — serve frontend-react/dist em "/", se compilado.
438
  # ----------------------------------------------------------------------
 
123
  <li><code>POST /run</code> — executa o pipeline (corpo JSON)</li>
124
  <li><code>GET /run?date=YYYY-MM-DD</code> — executa pelo navegador (mais fácil)</li>
125
  <li><code>GET /metrics/{data}</code> — lê as métricas já salvas (rápido, sem reexecutar)</li>
126
+ <li><code>GET /consulta/busca?termo=...</code> — busca de contas/oportunidades/contratos/itens</li>
127
+ <li><code>GET /consulta/objeto/{tipo}/{id}</code> — detalhe completo de um registro</li>
128
  <li><a href="/docs">/docs</a> — documentação interativa (Swagger)</li>
129
  </ul>
130
  <p>Se <code>APP_API_TOKEN</code> estiver definido, envie o cabeçalho
 
435
  )
436
 
437
 
438
+ # ----------------------------------------------------------------------
439
+ # Consulta (busca híbrida) — Contas, Oportunidades, Contratos, Itens.
440
+ # ----------------------------------------------------------------------
441
+ @app.get("/consulta/tipos")
442
+ def consulta_tipos(
443
+ x_api_key: str | None = Header(default=None, alias="X-API-Key"),
444
+ ) -> dict[str, Any]:
445
+ """Lista os tipos de busca disponíveis e o estado da configuração."""
446
+ _exigir_token(x_api_key)
447
+ from src.query.search_service import status_tipos
448
+
449
+ return status_tipos(get_settings())
450
+
451
+
452
+ @app.get("/consulta/busca")
453
+ def consulta_busca(
454
+ termo: str,
455
+ tipos: str | None = None,
456
+ limite: int = 20,
457
+ x_api_key: str | None = Header(default=None, alias="X-API-Key"),
458
+ ) -> dict[str, Any]:
459
+ """Busca híbrida (cache no Turso + fallback ao vivo no Salesforce).
460
+
461
+ Args:
462
+ termo: Texto buscado (mínimo 2 caracteres).
463
+ tipos: Lista separada por vírgula (ex.: ``conta,contrato``). Vazio = todos.
464
+ limite: Máximo de resultados por tipo (padrão 20).
465
+ """
466
+ _exigir_token(x_api_key)
467
+ from src.database.turso_client import get_turso_client
468
+ from src.query.search_service import TIPOS_VALIDOS, buscar
469
+ from src.salesforce.client import get_salesforce_client
470
+
471
+ lista_tipos = [t.strip() for t in tipos.split(",") if t.strip()] if tipos else None
472
+ if lista_tipos:
473
+ invalidos = [t for t in lista_tipos if t not in TIPOS_VALIDOS]
474
+ if invalidos:
475
+ raise HTTPException(
476
+ status_code=400,
477
+ detail=f"Tipo(s) inválido(s): {invalidos}. Use: {list(TIPOS_VALIDOS)}.",
478
+ )
479
+
480
+ settings = get_settings()
481
+ try:
482
+ sf_client = get_salesforce_client(settings)
483
+ sf_client.connect()
484
+ resultados = buscar(settings, get_turso_client(), sf_client, termo, lista_tipos, limite)
485
+ return {"termo": termo, "resultados": resultados}
486
+ except Exception as exc:
487
+ raise HTTPException(
488
+ status_code=502, detail=f"Falha na busca: {type(exc).__name__}: {exc}"
489
+ )
490
+
491
+
492
+ @app.get("/consulta/objeto/{tipo}/{record_id}")
493
+ def consulta_detalhe(
494
+ tipo: str,
495
+ record_id: str,
496
+ x_api_key: str | None = Header(default=None, alias="X-API-Key"),
497
+ ) -> dict[str, Any]:
498
+ """Detalhe completo de um registro (Conta, Oportunidade, Contrato ou Item)."""
499
+ _exigir_token(x_api_key)
500
+ from src.database.turso_client import get_turso_client
501
+ from src.query.search_service import TIPOS_VALIDOS, detalhar
502
+ from src.salesforce.client import get_salesforce_client
503
+
504
+ if tipo not in TIPOS_VALIDOS:
505
+ raise HTTPException(
506
+ status_code=400, detail=f"Tipo inválido: {tipo!r}. Use: {list(TIPOS_VALIDOS)}."
507
+ )
508
+
509
+ settings = get_settings()
510
+ try:
511
+ sf_client = get_salesforce_client(settings)
512
+ sf_client.connect()
513
+ return detalhar(settings, get_turso_client(), sf_client, tipo, record_id)
514
+ except ValueError as exc:
515
+ raise HTTPException(status_code=404, detail=str(exc))
516
+ except Exception as exc:
517
+ raise HTTPException(
518
+ status_code=502, detail=f"Falha ao obter detalhe: {type(exc).__name__}: {exc}"
519
+ )
520
+
521
+
522
  # ----------------------------------------------------------------------
523
  # Painel React (estático) — serve frontend-react/dist em "/", se compilado.
524
  # ----------------------------------------------------------------------
frontend-react/src/App.jsx CHANGED
@@ -9,6 +9,7 @@ import Leads from "./views/Leads.jsx";
9
  import Tasks from "./views/Tasks.jsx";
10
  import Satisfaction from "./views/Satisfaction.jsx";
11
  import Cancellations from "./views/Cancellations.jsx";
 
12
  import Alerts from "./views/Alerts.jsx";
13
  import Trends from "./views/Trends.jsx";
14
  import Report from "./views/Report.jsx";
@@ -133,6 +134,7 @@ export default function App() {
133
  function renderConteudo() {
134
  if (view === "config") return <Config notificar={notificar} onConfigSalva={() => { checarSaude(); carregarDias(); }} />;
135
  if (view === "tendencias") return <Trends />;
 
136
 
137
  if (erroDia && !dia) {
138
  return (
 
9
  import Tasks from "./views/Tasks.jsx";
10
  import Satisfaction from "./views/Satisfaction.jsx";
11
  import Cancellations from "./views/Cancellations.jsx";
12
+ import Search from "./views/Search.jsx";
13
  import Alerts from "./views/Alerts.jsx";
14
  import Trends from "./views/Trends.jsx";
15
  import Report from "./views/Report.jsx";
 
134
  function renderConteudo() {
135
  if (view === "config") return <Config notificar={notificar} onConfigSalva={() => { checarSaude(); carregarDias(); }} />;
136
  if (view === "tendencias") return <Trends />;
137
+ if (view === "busca") return <Search notificar={notificar} />;
138
 
139
  if (erroDia && !dia) {
140
  return (
frontend-react/src/api.js CHANGED
@@ -67,3 +67,16 @@ export const adicionarEmailCc = (email) =>
67
  requisicao("/config/email-cc", { method: "POST", body: JSON.stringify({ email }) });
68
  export const removerEmailCc = (email) =>
69
  requisicao("/config/email-cc/" + encodeURIComponent(email), { method: "DELETE" });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  requisicao("/config/email-cc", { method: "POST", body: JSON.stringify({ email }) });
68
  export const removerEmailCc = (email) =>
69
  requisicao("/config/email-cc/" + encodeURIComponent(email), { method: "DELETE" });
70
+
71
+ // Consulta (busca/detalhe híbrida: cache Turso + Salesforce ao vivo).
72
+ // Somente leitura — espelha os tipos configurados em src/query/search_service.py.
73
+ export const consultaTipos = () => requisicao("/consulta/tipos");
74
+
75
+ export const consultaBuscar = (termo, tipos, limite = 20) => {
76
+ const params = new URLSearchParams({ termo, limite: String(limite) });
77
+ if (tipos && tipos.length) params.set("tipos", tipos.join(","));
78
+ return requisicao("/consulta/busca?" + params.toString());
79
+ };
80
+
81
+ export const consultaDetalhar = (tipo, recordId) =>
82
+ requisicao("/consulta/objeto/" + encodeURIComponent(tipo) + "/" + encodeURIComponent(recordId));
frontend-react/src/components/Sidebar.jsx CHANGED
@@ -5,6 +5,7 @@ const ITENS = [
5
  { v: "tarefas", i: "✅", t: "Tarefas" },
6
  { v: "satisfacao", i: "😊", t: "Satisfação" },
7
  { v: "cancelamentos", i: "❌", t: "Cancelamentos" },
 
8
  { v: "alertas", i: "🚨", t: "Alertas" },
9
  { v: "tendencias", i: "📈", t: "Tendências" },
10
  { v: "relatorio", i: "📄", t: "Relatório" },
@@ -15,10 +16,11 @@ export default function Sidebar({ view, onNavigate, aberta }) {
15
  return (
16
  <aside className={"sidebar" + (aberta ? " open" : "")}>
17
  <div className="sb-brand">
18
- <div className="logo">📊</div>
 
19
  <div>
20
  <h1>Analytical-Force</h1>
21
- <div className="s">Diagnóstico comercial</div>
22
  </div>
23
  </div>
24
  <nav>
 
5
  { v: "tarefas", i: "✅", t: "Tarefas" },
6
  { v: "satisfacao", i: "😊", t: "Satisfação" },
7
  { v: "cancelamentos", i: "❌", t: "Cancelamentos" },
8
+ { v: "busca", i: "🔎", t: "Consulta" },
9
  { v: "alertas", i: "🚨", t: "Alertas" },
10
  { v: "tendencias", i: "📈", t: "Tendências" },
11
  { v: "relatorio", i: "📄", t: "Relatório" },
 
16
  return (
17
  <aside className={"sidebar" + (aberta ? " open" : "")}>
18
  <div className="sb-brand">
19
+ {/* TODO: substituir pelo arquivo real da logo Penso (aguardando upload como anexo). */}
20
+ <div className="logo penso-logo">P</div>
21
  <div>
22
  <h1>Analytical-Force</h1>
23
+ <div className="s">Penso · Diagnóstico comercial</div>
24
  </div>
25
  </div>
26
  <nav>
frontend-react/src/style.css CHANGED
@@ -1,34 +1,37 @@
1
  :root{
2
- --c1:#6366f1;--c2:#8b5cf6;--c3:#ec4899;
3
- --bg:#f5f6fb;--surface:#ffffff;--surface2:#f7f8fd;--line:#eceef5;
4
- --txt:#1e2440;--muted:#6b7280;--faint:#9aa3b2;
5
- --ok:#16a34a;--high:#ef4444;--med:#f59e0b;--low:#eab308;
6
- --blue:#2563eb;--green:#16a34a;--red:#e11d48;--amber:#d97706;--violet:#7c3aed;--teal:#0d9488;
7
- --radius:18px;--shadow:0 10px 28px rgba(40,40,90,.08);--shadow-lg:0 26px 60px rgba(40,40,90,.22);--sb:248px;
 
 
8
  }
9
- [data-theme="dark"]{--bg:#0e1020;--surface:#171a2e;--surface2:#1c2138;--line:#272c47;--txt:#e8eaf5;--muted:#9aa3c0;--faint:#6c7595;
10
  --shadow:0 10px 28px rgba(0,0,0,.45);--shadow-lg:0 26px 60px rgba(0,0,0,.6)}
11
  *{box-sizing:border-box}
12
  body{margin:0;font-family:'Segoe UI',system-ui,-apple-system,Arial,sans-serif;color:var(--txt);
13
- background:radial-gradient(1200px 500px at 100% -10%,rgba(139,92,246,.10),transparent),
14
- radial-gradient(900px 400px at -10% 0%,rgba(236,72,153,.08),transparent),var(--bg);
15
  min-height:100vh}
16
  a{color:var(--c1)}
17
  .app{display:flex;min-height:100vh}
18
- .sidebar{width:var(--sb);background:linear-gradient(170deg,#6366f1 0%,#7c3aed 52%,#c026d3 120%);color:#fff;position:fixed;inset:0 auto 0 0;
19
  display:flex;flex-direction:column;padding:16px 12px;z-index:40;transition:transform .25s}
20
  .sb-brand{display:flex;align-items:center;gap:11px;padding:6px 10px 16px;margin-bottom:6px;border-bottom:1px solid rgba(255,255,255,.18)}
21
  .sb-brand .logo{width:40px;height:40px;border-radius:13px;background:rgba(255,255,255,.22);display:grid;place-items:center;font-size:20px}
22
- .sb-brand h1{font-size:15px;margin:0;font-weight:800;letter-spacing:.2px} .sb-brand .s{font-size:11px;color:#ece6ff}
 
23
  nav{display:flex;flex-direction:column;gap:3px;overflow:auto;margin-top:6px}
24
- nav a{display:flex;align-items:center;gap:12px;color:#f0ecff;text-decoration:none;padding:10px 12px;border-radius:12px;
25
  font-size:13.5px;font-weight:600;cursor:pointer;transition:.18s}
26
  nav a .i{width:22px;text-align:center;font-size:16px}
27
  nav a:hover{background:rgba(255,255,255,.14);color:#fff;transform:translateX(2px)}
28
- nav a.active{background:#fff;color:#6d28d9;box-shadow:0 6px 16px rgba(0,0,0,.18)}
29
  .sb-foot{margin-top:auto;padding-top:14px;display:flex;align-items:center;gap:10px;border-top:1px solid rgba(255,255,255,.18)}
30
  .sb-foot img{width:38px;height:38px;border-radius:50%;border:2px solid rgba(255,255,255,.65)}
31
- .sb-foot .n{font-size:12.5px;font-weight:700} .sb-foot .r{font-size:11px;color:#ece6ff}
32
  .mainwrap{margin-left:var(--sb);flex:1;min-width:0;display:flex;flex-direction:column}
33
  .topbar{position:sticky;top:0;z-index:30;background:rgba(255,255,255,.82);backdrop-filter:blur(10px);
34
  border-bottom:1px solid var(--line);padding:12px 22px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
@@ -62,7 +65,7 @@ main{padding:22px;flex:1}
62
  .meta{display:flex;gap:8px;flex-wrap:wrap}
63
  .chip{background:var(--surface2);border:1px solid var(--line);color:var(--txt);border-radius:999px;padding:6px 12px;font-size:12.5px;font-weight:600}
64
  .chip.good{background:#dcfce7;color:#15803d;border-color:#bbf7d0} .chip.bad{background:#ffe4e6;color:#be123c;border-color:#fecdd3}
65
- .hero{border-radius:14px;padding:16px 18px;background:linear-gradient(120deg,rgba(99,102,241,.10),rgba(236,72,153,.08));border:1px solid rgba(124,58,237,.18);border-left:4px solid var(--c2);margin-bottom:16px}
66
  .hero .h{font-size:12px;font-weight:800;color:var(--c2);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}
67
  .hero p{margin:0 0 10px;font-size:14px;line-height:1.6} .hero p:last-child{margin-bottom:0}
68
  .hero strong{color:var(--txt)}
@@ -141,3 +144,4 @@ input:focus{outline:none;border-color:var(--c1);box-shadow:0 0 0 3px rgba(99,102
141
  .ccrow button{background:none;border:0;color:var(--red);cursor:pointer;font-size:13px;font-weight:700}
142
  .ccadd{display:flex;gap:8px;margin-top:14px}
143
  @media(max-width:860px){.sidebar{transform:translateX(-100%)} .sidebar.open{transform:none} .mainwrap{margin-left:0} .hamb{display:block} .split{grid-template-columns:1fr}}
 
 
1
  :root{
2
+ /* Paleta Penso (02 - Cores.pdf): Blue #0018FF, Blue Tech #0004DD, Black Intense #060606. */
3
+ --c1:#0018FF;--c2:#0004DD;--c3:#FFC453;
4
+ --bg:#f5f6fb;--surface:#ffffff;--surface2:#F7F8FA;--line:#E6EAEE;
5
+ --txt:#171717;--muted:#6b7280;--faint:#9aa3b2;
6
+ --ok:#16C79A;--high:#F05454;--med:#FFC453;--low:#eab308;
7
+ --blue:#0018FF;--green:#16C79A;--red:#F05454;--amber:#FFC453;--violet:#0004DD;--teal:#16C79A;
8
+ --penso-blue:#0018FF;--penso-bluetech:#0004DD;--penso-black:#171717;--penso-blackx:#060606;
9
+ --radius:18px;--shadow:0 10px 28px rgba(6,6,6,.08);--shadow-lg:0 26px 60px rgba(6,6,6,.22);--sb:248px;
10
  }
11
+ [data-theme="dark"]{--bg:#0b0b0f;--surface:#141520;--surface2:#191b28;--line:#272c47;--txt:#e8eaf5;--muted:#9aa3c0;--faint:#6c7595;
12
  --shadow:0 10px 28px rgba(0,0,0,.45);--shadow-lg:0 26px 60px rgba(0,0,0,.6)}
13
  *{box-sizing:border-box}
14
  body{margin:0;font-family:'Segoe UI',system-ui,-apple-system,Arial,sans-serif;color:var(--txt);
15
+ background:radial-gradient(1200px 500px at 100% -10%,rgba(0,24,255,.08),transparent),
16
+ radial-gradient(900px 400px at -10% 0%,rgba(255,196,83,.07),transparent),var(--bg);
17
  min-height:100vh}
18
  a{color:var(--c1)}
19
  .app{display:flex;min-height:100vh}
20
+ .sidebar{width:var(--sb);background:linear-gradient(170deg,#060606 0%,#0004DD 55%,#0018FF 120%);color:#fff;position:fixed;inset:0 auto 0 0;
21
  display:flex;flex-direction:column;padding:16px 12px;z-index:40;transition:transform .25s}
22
  .sb-brand{display:flex;align-items:center;gap:11px;padding:6px 10px 16px;margin-bottom:6px;border-bottom:1px solid rgba(255,255,255,.18)}
23
  .sb-brand .logo{width:40px;height:40px;border-radius:13px;background:rgba(255,255,255,.22);display:grid;place-items:center;font-size:20px}
24
+ .sb-brand .penso-logo{background:#fff;color:#0018FF;font-weight:900;font-size:20px;font-family:Arial,sans-serif}
25
+ .sb-brand h1{font-size:15px;margin:0;font-weight:800;letter-spacing:.2px} .sb-brand .s{font-size:11px;color:#CEE2FF}
26
  nav{display:flex;flex-direction:column;gap:3px;overflow:auto;margin-top:6px}
27
+ nav a{display:flex;align-items:center;gap:12px;color:#EAF0FF;text-decoration:none;padding:10px 12px;border-radius:12px;
28
  font-size:13.5px;font-weight:600;cursor:pointer;transition:.18s}
29
  nav a .i{width:22px;text-align:center;font-size:16px}
30
  nav a:hover{background:rgba(255,255,255,.14);color:#fff;transform:translateX(2px)}
31
+ nav a.active{background:#fff;color:#0018FF;box-shadow:0 6px 16px rgba(0,0,0,.18)}
32
  .sb-foot{margin-top:auto;padding-top:14px;display:flex;align-items:center;gap:10px;border-top:1px solid rgba(255,255,255,.18)}
33
  .sb-foot img{width:38px;height:38px;border-radius:50%;border:2px solid rgba(255,255,255,.65)}
34
+ .sb-foot .n{font-size:12.5px;font-weight:700} .sb-foot .r{font-size:11px;color:#CEE2FF}
35
  .mainwrap{margin-left:var(--sb);flex:1;min-width:0;display:flex;flex-direction:column}
36
  .topbar{position:sticky;top:0;z-index:30;background:rgba(255,255,255,.82);backdrop-filter:blur(10px);
37
  border-bottom:1px solid var(--line);padding:12px 22px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
 
65
  .meta{display:flex;gap:8px;flex-wrap:wrap}
66
  .chip{background:var(--surface2);border:1px solid var(--line);color:var(--txt);border-radius:999px;padding:6px 12px;font-size:12.5px;font-weight:600}
67
  .chip.good{background:#dcfce7;color:#15803d;border-color:#bbf7d0} .chip.bad{background:#ffe4e6;color:#be123c;border-color:#fecdd3}
68
+ .hero{border-radius:14px;padding:16px 18px;background:linear-gradient(120deg,rgba(0,24,255,.08),rgba(255,196,83,.08));border:1px solid rgba(0,4,221,.18);border-left:4px solid var(--c2);margin-bottom:16px}
69
  .hero .h{font-size:12px;font-weight:800;color:var(--c2);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}
70
  .hero p{margin:0 0 10px;font-size:14px;line-height:1.6} .hero p:last-child{margin-bottom:0}
71
  .hero strong{color:var(--txt)}
 
144
  .ccrow button{background:none;border:0;color:var(--red);cursor:pointer;font-size:13px;font-weight:700}
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
+
frontend-react/src/views/Search.jsx ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import SecaoHeader from "../components/SecaoHeader.jsx";
3
+ import { consultaTipos, consultaBuscar, consultaDetalhar } from "../api.js";
4
+
5
+ // Tela de Consulta: busca e detalhe somente-leitura sobre Salesforce, com
6
+ // cache Turso como camada rápida (modo híbrido). Nenhum cálculo é feito
7
+ // aqui — apenas exibição do que o backend Python já retornou.
8
+
9
+ const ROTULOS_TIPO = {
10
+ conta: { icone: "🏢", cor: "#0018FF" },
11
+ oportunidade: { icone: "💼", cor: "#16C79A" },
12
+ contrato: { icone: "📄", cor: "#0004DD" },
13
+ item: { icone: "🧾", cor: "#FFC453" },
14
+ };
15
+
16
+ export default function Search({ notificar }) {
17
+ const [tiposInfo, setTiposInfo] = useState(null);
18
+ const [tiposAtivos, setTiposAtivos] = useState([]);
19
+ const [termo, setTermo] = useState("");
20
+ const [buscando, setBuscando] = useState(false);
21
+ const [resultados, setResultados] = useState(null);
22
+ const [erroBusca, setErroBusca] = useState(null);
23
+
24
+ const [selecionado, setSelecionado] = useState(null); // { tipo, id, name }
25
+ const [detalhe, setDetalhe] = useState(null);
26
+ const [carregandoDetalhe, setCarregandoDetalhe] = useState(false);
27
+ const [erroDetalhe, setErroDetalhe] = useState(null);
28
+
29
+ useEffect(() => {
30
+ consultaTipos()
31
+ .then((d) => {
32
+ setTiposInfo(d);
33
+ setTiposAtivos((d.tipos || []).map((t) => t.tipo));
34
+ })
35
+ .catch(() => setTiposInfo(null));
36
+ }, []);
37
+
38
+ const tiposDisponiveis = useMemo(() => tiposInfo?.tipos || [], [tiposInfo]);
39
+
40
+ function alternarTipo(tipo) {
41
+ setTiposAtivos((atuais) =>
42
+ atuais.includes(tipo) ? atuais.filter((t) => t !== tipo) : [...atuais, tipo]
43
+ );
44
+ }
45
+
46
+ async function executarBusca(e) {
47
+ e?.preventDefault();
48
+ const t = termo.trim();
49
+ if (t.length < 2) {
50
+ setErroBusca("Digite ao menos 2 caracteres.");
51
+ return;
52
+ }
53
+ setBuscando(true);
54
+ setErroBusca(null);
55
+ setResultados(null);
56
+ try {
57
+ const d = await consultaBuscar(t, tiposAtivos, 20);
58
+ setResultados(d.resultados || {});
59
+ } catch (err) {
60
+ setErroBusca(err.message);
61
+ notificar?.("Falha na busca: " + err.message, "bad");
62
+ } finally {
63
+ setBuscando(false);
64
+ }
65
+ }
66
+
67
+ async function abrirDetalhe(tipo, item) {
68
+ setSelecionado({ tipo, id: item.id, name: item.name });
69
+ setDetalhe(null);
70
+ setErroDetalhe(null);
71
+ setCarregandoDetalhe(true);
72
+ try {
73
+ const d = await consultaDetalhar(tipo, item.id);
74
+ setDetalhe(d);
75
+ } catch (err) {
76
+ setErroDetalhe(err.message);
77
+ } finally {
78
+ setCarregandoDetalhe(false);
79
+ }
80
+ }
81
+
82
+ const totalResultados = resultados
83
+ ? Object.values(resultados).reduce((acc, arr) => acc + arr.length, 0)
84
+ : 0;
85
+
86
+ return (
87
+ <>
88
+ <div className="card">
89
+ <SecaoHeader
90
+ icone="🔎"
91
+ titulo="Consulta — Contratos, Clientes e Oportunidades"
92
+ cor="linear-gradient(120deg,#0004DD,#0018FF)"
93
+ extra={
94
+ <span className="chip">
95
+ Fonte: cache Turso + Salesforce (somente leitura)
96
+ </span>
97
+ }
98
+ />
99
+
100
+ <form onSubmit={executarBusca}>
101
+ <label className="lbl">Buscar por nome</label>
102
+ <div style={{ display: "flex", gap: 9 }}>
103
+ <input
104
+ type="text"
105
+ placeholder="Ex.: Contrato 4521, Empresa XPTO, Oportunidade Renovação…"
106
+ value={termo}
107
+ onChange={(e) => setTermo(e.target.value)}
108
+ autoFocus
109
+ />
110
+ <button className="btn btn-primary btn-sm" type="submit" disabled={buscando}>
111
+ {buscando ? "Buscando…" : "Buscar"}
112
+ </button>
113
+ </div>
114
+ </form>
115
+
116
+ <div className="filters" style={{ marginTop: 14 }}>
117
+ {tiposDisponiveis.map((t) => (
118
+ <span
119
+ key={t.tipo}
120
+ className={"fchip" + (tiposAtivos.includes(t.tipo) ? " active" : "")}
121
+ onClick={() => alternarTipo(t.tipo)}
122
+ title={t.objeto}
123
+ >
124
+ {ROTULOS_TIPO[t.tipo]?.icone || "•"} {t.rotulo}
125
+ </span>
126
+ ))}
127
+ </div>
128
+
129
+ {tiposInfo && !tiposInfo.itens_do_contrato_configurados && (
130
+ <div className="empty" style={{ textAlign: "left", padding: "10px 0 0", fontSize: 12.5 }}>
131
+ ⚙️ Vínculo Contrato → Itens não configurado (defina{" "}
132
+ <code>SF_CONTRACT_ITEM_PARENT_FIELD</code> no .env). O contrato ainda pode ser
133
+ consultado, mas sem a lista de itens relacionados.
134
+ </div>
135
+ )}
136
+
137
+ {erroBusca && <div className="err" style={{ marginTop: 14 }}>{erroBusca}</div>}
138
+ </div>
139
+
140
+ <div className="split">
141
+ <div className="card">
142
+ <SecaoHeader
143
+ icone="📋"
144
+ titulo={resultados ? `Resultados (${totalResultados})` : "Resultados"}
145
+ cor="#0018FF"
146
+ />
147
+ {buscando && <div className="empty">Buscando��</div>}
148
+ {!buscando && !resultados && (
149
+ <div className="empty">Digite um termo e clique em Buscar.</div>
150
+ )}
151
+ {!buscando && resultados && totalResultados === 0 && (
152
+ <div className="empty">Nenhum resultado para "{termo}".</div>
153
+ )}
154
+ {!buscando &&
155
+ resultados &&
156
+ Object.entries(resultados).map(([tipo, itens]) => (
157
+ <div key={tipo} style={{ marginBottom: 16 }}>
158
+ <div
159
+ style={{
160
+ fontSize: 12,
161
+ fontWeight: 800,
162
+ textTransform: "uppercase",
163
+ letterSpacing: ".05em",
164
+ color: "var(--muted)",
165
+ marginBottom: 8,
166
+ }}
167
+ >
168
+ {ROTULOS_TIPO[tipo]?.icone} {tiposInfo?.tipos?.find((t) => t.tipo === tipo)?.rotulo || tipo}
169
+ </div>
170
+ <div className="dgrid">
171
+ {itens.map((item) => (
172
+ <div
173
+ key={item.id}
174
+ className="dcard"
175
+ style={{
176
+ cursor: "pointer",
177
+ border:
178
+ selecionado?.id === item.id ? "2px solid #0018FF" : "1px solid var(--line)",
179
+ }}
180
+ onClick={() => abrirDetalhe(tipo, item)}
181
+ >
182
+ <div className="dh" style={{ background: ROTULOS_TIPO[tipo]?.cor || "#0018FF" }}>
183
+ <span>{item.name}</span>
184
+ </div>
185
+ {item.subtitle && (
186
+ <div style={{ padding: "10px 14px", fontSize: 12.5, color: "var(--muted)" }}>
187
+ {item.subtitle}
188
+ </div>
189
+ )}
190
+ </div>
191
+ ))}
192
+ </div>
193
+ </div>
194
+ ))}
195
+ </div>
196
+
197
+ <div className="card">
198
+ <SecaoHeader icone="🗂️" titulo="Detalhe" cor="#171717" />
199
+ {!selecionado && <div className="empty">Selecione um resultado para ver os detalhes.</div>}
200
+ {selecionado && carregandoDetalhe && <div className="empty">Carregando detalhe…</div>}
201
+ {selecionado && erroDetalhe && <div className="err">{erroDetalhe}</div>}
202
+ {selecionado && detalhe && (
203
+ <>
204
+ <div className="chip" style={{ marginBottom: 12 }}>
205
+ Origem: {detalhe.origem}
206
+ </div>
207
+ <table className="owners">
208
+ <tbody>
209
+ {(detalhe.campos || []).map((c) => (
210
+ <tr key={c.campo}>
211
+ <th style={{ whiteSpace: "nowrap" }}>{c.campo}</th>
212
+ <td>{String(c.valor)}</td>
213
+ </tr>
214
+ ))}
215
+ </tbody>
216
+ </table>
217
+
218
+ {detalhe.itens_do_contrato != null && (
219
+ <div style={{ marginTop: 18 }}>
220
+ <SecaoHeader icone="🧾" titulo={`Itens do contrato (${detalhe.itens_do_contrato.length})`} cor="#FFC453" />
221
+ {detalhe.itens_do_contrato.length === 0 ? (
222
+ <div className="empty">Nenhum item vinculado.</div>
223
+ ) : (
224
+ <div className="dgrid">
225
+ {detalhe.itens_do_contrato.map((it, idx) => (
226
+ <div className="dcard" key={it.Id || idx}>
227
+ <div className="dh" style={{ background: "#FFC453", color: "#171717" }}>
228
+ {it.Name || it.Id}
229
+ </div>
230
+ <ul>
231
+ {Object.entries(it)
232
+ .filter(([k, v]) => k !== "attributes" && v != null && k !== "Name")
233
+ .slice(0, 6)
234
+ .map(([k, v]) => (
235
+ <li key={k}>
236
+ <b>{k}:</b> {String(v)}
237
+ </li>
238
+ ))}
239
+ </ul>
240
+ </div>
241
+ ))}
242
+ </div>
243
+ )}
244
+ </div>
245
+ )}
246
+ </>
247
+ )}
248
+ </div>
249
+ </div>
250
+ </>
251
+ );
252
+ }
frontend-react/vite.config.js.timestamp-1783562543181-33acbd5d299df.mjs ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // vite.config.js
2
+ import { defineConfig } from "file:///sessions/zen-zealous-feynman/mnt/Analytical-Force/analytical_force/frontend-react/node_modules/vite/dist/node/index.js";
3
+ import react from "file:///sessions/zen-zealous-feynman/mnt/Analytical-Force/analytical_force/frontend-react/node_modules/@vitejs/plugin-react/dist/index.js";
4
+ var vite_config_default = defineConfig({
5
+ plugins: [react()],
6
+ base: "./",
7
+ build: {
8
+ outDir: "dist",
9
+ emptyOutDir: true
10
+ }
11
+ });
12
+ export {
13
+ vite_config_default as default
14
+ };
15
+ //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvc2Vzc2lvbnMvemVuLXplYWxvdXMtZmV5bm1hbi9tbnQvQW5hbHl0aWNhbC1Gb3JjZS9hbmFseXRpY2FsX2ZvcmNlL2Zyb250ZW5kLXJlYWN0XCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvc2Vzc2lvbnMvemVuLXplYWxvdXMtZmV5bm1hbi9tbnQvQW5hbHl0aWNhbC1Gb3JjZS9hbmFseXRpY2FsX2ZvcmNlL2Zyb250ZW5kLXJlYWN0L3ZpdGUuY29uZmlnLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9zZXNzaW9ucy96ZW4temVhbG91cy1mZXlubWFuL21udC9BbmFseXRpY2FsLUZvcmNlL2FuYWx5dGljYWxfZm9yY2UvZnJvbnRlbmQtcmVhY3Qvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiO1xuaW1wb3J0IHJlYWN0IGZyb20gXCJAdml0ZWpzL3BsdWdpbi1yZWFjdFwiO1xuXG4vLyBQYWluZWwgUmVhY3QgZG8gQW5hbHl0aWNhbC1Gb3JjZS5cbi8vIFwiYmFzZTogJy4vJ1wiIGdlcmEgY2FtaW5ob3MgcmVsYXRpdm9zIG5vIGJ1aWxkIFx1MjAxNCBpbXBvcnRhbnRlIHBvcnF1ZSBvXG4vLyBiYWNrZW5kIChGYXN0QVBJKSBzZXJ2ZSBvIGRpc3QvIGNvbW8gYXJxdWl2b3MgZXN0XHUwMEUxdGljb3MgYSBwYXJ0aXIgZGFcbi8vIHJhaXogZG8gY29udGFpbmVyIG5vIEh1Z2dpbmcgRmFjZSBTcGFjZXMgKHZlciBzcmMvZGVsaXZlcnkvc3RhdGljX2FwcC5weSkuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbcmVhY3QoKV0sXG4gIGJhc2U6IFwiLi9cIixcbiAgYnVpbGQ6IHtcbiAgICBvdXREaXI6IFwiZGlzdFwiLFxuICAgIGVtcHR5T3V0RGlyOiB0cnVlLFxuICB9LFxufSk7XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQXdhLFNBQVMsb0JBQW9CO0FBQ3JjLE9BQU8sV0FBVztBQU1sQixJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMxQixTQUFTLENBQUMsTUFBTSxDQUFDO0FBQUEsRUFDakIsTUFBTTtBQUFBLEVBQ04sT0FBTztBQUFBLElBQ0wsUUFBUTtBQUFBLElBQ1IsYUFBYTtBQUFBLEVBQ2Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=
frontend-react/vite.config.js.timestamp-1783562598611-6eea937b4fc98.mjs ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // vite.config.js
2
+ import { defineConfig } from "file:///sessions/zen-zealous-feynman/mnt/Analytical-Force/analytical_force/frontend-react/node_modules/vite/dist/node/index.js";
3
+ import react from "file:///sessions/zen-zealous-feynman/mnt/Analytical-Force/analytical_force/frontend-react/node_modules/@vitejs/plugin-react/dist/index.js";
4
+ var vite_config_default = defineConfig({
5
+ plugins: [react()],
6
+ base: "./",
7
+ build: {
8
+ outDir: "dist",
9
+ emptyOutDir: true
10
+ }
11
+ });
12
+ export {
13
+ vite_config_default as default
14
+ };
15
+ //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvc2Vzc2lvbnMvemVuLXplYWxvdXMtZmV5bm1hbi9tbnQvQW5hbHl0aWNhbC1Gb3JjZS9hbmFseXRpY2FsX2ZvcmNlL2Zyb250ZW5kLXJlYWN0XCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvc2Vzc2lvbnMvemVuLXplYWxvdXMtZmV5bm1hbi9tbnQvQW5hbHl0aWNhbC1Gb3JjZS9hbmFseXRpY2FsX2ZvcmNlL2Zyb250ZW5kLXJlYWN0L3ZpdGUuY29uZmlnLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9zZXNzaW9ucy96ZW4temVhbG91cy1mZXlubWFuL21udC9BbmFseXRpY2FsLUZvcmNlL2FuYWx5dGljYWxfZm9yY2UvZnJvbnRlbmQtcmVhY3Qvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiO1xuaW1wb3J0IHJlYWN0IGZyb20gXCJAdml0ZWpzL3BsdWdpbi1yZWFjdFwiO1xuXG4vLyBQYWluZWwgUmVhY3QgZG8gQW5hbHl0aWNhbC1Gb3JjZS5cbi8vIFwiYmFzZTogJy4vJ1wiIGdlcmEgY2FtaW5ob3MgcmVsYXRpdm9zIG5vIGJ1aWxkIFx1MjAxNCBpbXBvcnRhbnRlIHBvcnF1ZSBvXG4vLyBiYWNrZW5kIChGYXN0QVBJKSBzZXJ2ZSBvIGRpc3QvIGNvbW8gYXJxdWl2b3MgZXN0XHUwMEUxdGljb3MgYSBwYXJ0aXIgZGFcbi8vIHJhaXogZG8gY29udGFpbmVyIG5vIEh1Z2dpbmcgRmFjZSBTcGFjZXMgKHZlciBzcmMvZGVsaXZlcnkvc3RhdGljX2FwcC5weSkuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbcmVhY3QoKV0sXG4gIGJhc2U6IFwiLi9cIixcbiAgYnVpbGQ6IHtcbiAgICBvdXREaXI6IFwiZGlzdFwiLFxuICAgIGVtcHR5T3V0RGlyOiB0cnVlLFxuICB9LFxufSk7XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQXdhLFNBQVMsb0JBQW9CO0FBQ3JjLE9BQU8sV0FBVztBQU1sQixJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMxQixTQUFTLENBQUMsTUFBTSxDQUFDO0FBQUEsRUFDakIsTUFBTTtBQUFBLEVBQ04sT0FBTztBQUFBLElBQ0wsUUFBUTtBQUFBLElBQ1IsYUFBYTtBQUFBLEVBQ2Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=
src/agent/analytical_force_agent.py CHANGED
@@ -31,18 +31,21 @@ from ..database.migrations import run_migrations
31
  from ..database.repositories import (
32
  AgentRunRepository,
33
  AlertsRepository,
 
34
  MetricsRepository,
35
  ObjectMappingRepository,
36
  ReportRepository,
 
37
  SnapshotRepository,
38
  )
39
  from ..database.turso_client import get_turso_client
40
  from ..delivery.file_writer import salvar_relatorio_md
41
  from ..models.model_router import ModelRouter
42
  from ..models.template_client import gerar_plano_acao
43
- from ..salesforce.client import SalesforceAuthError, get_salesforce_client
44
  from ..salesforce.extractors import SalesforceExtractor
45
  from ..salesforce.field_mapping import get_field_mapping
 
46
  from ..utils.date_utils import agora_tz, para_soql_date
47
  from ..utils.logger import get_logger
48
  from .report_generator import gerar_relatorio
@@ -148,7 +151,7 @@ class AnalyticalForceAgent:
148
  resultado.run_id = run_id
149
 
150
  # --- Extração do Salesforce ---
151
- extrator = self._montar_extrator(snapshot_repo)
152
  dados = self._extrair(extrator, dia_ref)
153
 
154
  # --- Histórico para comparação ---
@@ -207,6 +210,23 @@ class AnalyticalForceAgent:
207
  caminho = salvar_relatorio_md(markdown, dia_ref)
208
  resultado.caminho_relatorio = str(caminho)
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  run_repo.finalizar_execucao(run_id, "success")
211
  resultado.status = "success"
212
  logger.info("Execução concluída com sucesso (provider=%s).", provider)
@@ -228,21 +248,29 @@ class AnalyticalForceAgent:
228
  # ------------------------------------------------------------------
229
  # Etapas internas
230
  # ------------------------------------------------------------------
231
- def _montar_extrator(self, snapshot_repo: SnapshotRepository) -> SalesforceExtractor:
 
 
232
  """Autentica no Salesforce e devolve o extrator configurado.
233
 
234
  Usa o cliente com as configurações do agente. Por padrão, a
235
  autenticação ocorre via OAuth Refresh Token (somente leitura).
 
 
 
 
 
236
  """
237
  sf_client = get_salesforce_client(self._settings)
238
  sf_client.connect() # valida credenciais cedo (levanta erro controlado)
239
- return SalesforceExtractor(
240
  client=sf_client,
241
  field_mapping=get_field_mapping(),
242
  timezone=self._tz,
243
  snapshot_repo=snapshot_repo,
244
  ignore_lead_names=self._settings.lead_ignore_names,
245
  )
 
246
 
247
  def _extrair(self, extrator: SalesforceExtractor, dia: date) -> dict[str, pd.DataFrame]:
248
  """Extrai todos os DataFrames necessários do Salesforce."""
@@ -423,11 +451,23 @@ class AnalyticalForceAgent:
423
 
424
  opp_fech = dados.get("opp_fechadas")
425
  if opp_fech is not None and not opp_fech.empty and "IsWon" in opp_fech.columns:
426
- ganhas = opp_fech[opp_fech["IsWon"].fillna(False).astype(bool)]
 
427
  destaques["oportunidades_ganhas"] = [
428
  _reg(r.get("Id"), r.get("Name"), self._info_moeda(r.get("Amount")))
429
  for _, r in ganhas.head(limite).iterrows()
430
  ]
 
 
 
 
 
 
 
 
 
 
 
431
 
432
  travadas = (metricas.get("opportunities", {}) or {}).get(
433
  "stalled_opportunity_details"
 
31
  from ..database.repositories import (
32
  AgentRunRepository,
33
  AlertsRepository,
34
+ ConfigRepository,
35
  MetricsRepository,
36
  ObjectMappingRepository,
37
  ReportRepository,
38
+ SearchCacheRepository,
39
  SnapshotRepository,
40
  )
41
  from ..database.turso_client import get_turso_client
42
  from ..delivery.file_writer import salvar_relatorio_md
43
  from ..models.model_router import ModelRouter
44
  from ..models.template_client import gerar_plano_acao
45
+ from ..salesforce.client import SalesforceAuthError, SalesforceClient, get_salesforce_client
46
  from ..salesforce.extractors import SalesforceExtractor
47
  from ..salesforce.field_mapping import get_field_mapping
48
+ from ..salesforce.search_sync import sincronizar_cache_busca
49
  from ..utils.date_utils import agora_tz, para_soql_date
50
  from ..utils.logger import get_logger
51
  from .report_generator import gerar_relatorio
 
151
  resultado.run_id = run_id
152
 
153
  # --- Extração do Salesforce ---
154
+ extrator, sf_client = self._montar_extrator(snapshot_repo)
155
  dados = self._extrair(extrator, dia_ref)
156
 
157
  # --- Histórico para comparação ---
 
210
  caminho = salvar_relatorio_md(markdown, dia_ref)
211
  resultado.caminho_relatorio = str(caminho)
212
 
213
+ # --- Cache de busca (módulo de Consulta) — best-effort ---
214
+ # Sincroniza Account/Opportunity/Contrato/Item de Contrato de forma
215
+ # incremental. Nunca deve impedir o sucesso do pipeline principal.
216
+ try:
217
+ sync_resultado = sincronizar_cache_busca(
218
+ sf_client,
219
+ self._settings,
220
+ ConfigRepository(turso),
221
+ SearchCacheRepository(turso),
222
+ )
223
+ logger.info("Cache de busca sincronizado: %s", sync_resultado)
224
+ except Exception as exc:
225
+ logger.warning(
226
+ "Falha ao sincronizar cache de busca (não bloqueia o pipeline): %s",
227
+ type(exc).__name__,
228
+ )
229
+
230
  run_repo.finalizar_execucao(run_id, "success")
231
  resultado.status = "success"
232
  logger.info("Execução concluída com sucesso (provider=%s).", provider)
 
248
  # ------------------------------------------------------------------
249
  # Etapas internas
250
  # ------------------------------------------------------------------
251
+ def _montar_extrator(
252
+ self, snapshot_repo: SnapshotRepository
253
+ ) -> tuple[SalesforceExtractor, SalesforceClient]:
254
  """Autentica no Salesforce e devolve o extrator configurado.
255
 
256
  Usa o cliente com as configurações do agente. Por padrão, a
257
  autenticação ocorre via OAuth Refresh Token (somente leitura).
258
+
259
+ Returns:
260
+ Tupla ``(extrator, cliente_sf)`` — o cliente autenticado também é
261
+ devolvido para ser reaproveitado pela sincronização do cache de
262
+ busca (evita autenticar duas vezes na mesma execução).
263
  """
264
  sf_client = get_salesforce_client(self._settings)
265
  sf_client.connect() # valida credenciais cedo (levanta erro controlado)
266
+ extrator = SalesforceExtractor(
267
  client=sf_client,
268
  field_mapping=get_field_mapping(),
269
  timezone=self._tz,
270
  snapshot_repo=snapshot_repo,
271
  ignore_lead_names=self._settings.lead_ignore_names,
272
  )
273
+ return extrator, sf_client
274
 
275
  def _extrair(self, extrator: SalesforceExtractor, dia: date) -> dict[str, pd.DataFrame]:
276
  """Extrai todos os DataFrames necessários do Salesforce."""
 
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)
455
+ ganhas = opp_fech[ganha_bool]
456
  destaques["oportunidades_ganhas"] = [
457
  _reg(r.get("Id"), r.get("Name"), self._info_moeda(r.get("Amount")))
458
  for _, r in ganhas.head(limite).iterrows()
459
  ]
460
+ # Espelha exatamente a lógica das ganhas, para o lado perdido —
461
+ # alimenta o e-mail/painel com nomes e links (não só o agregado).
462
+ perdidas = opp_fech[~ganha_bool]
463
+ destaques["oportunidades_perdidas"] = [
464
+ _reg(
465
+ r.get("Id"),
466
+ r.get("Name"),
467
+ self._info_moeda(r.get("Amount")),
468
+ )
469
+ for _, r in perdidas.head(limite).iterrows()
470
+ ]
471
 
472
  travadas = (metricas.get("opportunities", {}) or {}).get(
473
  "stalled_opportunity_details"
src/config/settings.py CHANGED
@@ -324,6 +324,11 @@ class Settings:
324
  # Permitem ativar esses módulos sem fixar nomes no código (regra 16/17).
325
  satisfaction_source: dict[str, Any] = field(default_factory=dict)
326
  cancellation_source: dict[str, Any] = field(default_factory=dict)
 
 
 
 
 
327
 
328
  # ------------------------------------------------------------------
329
  # Validações
@@ -549,6 +554,32 @@ def _carregar_fonte_cancelamento() -> dict[str, Any]:
549
  }
550
 
551
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  @lru_cache(maxsize=1)
553
  def get_settings() -> Settings:
554
  """Constrói (uma única vez) e retorna as configurações do agente.
@@ -659,4 +690,5 @@ def get_settings() -> Settings:
659
  ],
660
  satisfaction_source=_carregar_fonte_satisfacao(),
661
  cancellation_source=_carregar_fonte_cancelamento(),
 
662
  )
 
324
  # Permitem ativar esses módulos sem fixar nomes no código (regra 16/17).
325
  satisfaction_source: dict[str, Any] = field(default_factory=dict)
326
  cancellation_source: dict[str, Any] = field(default_factory=dict)
327
+ # Fonte configurável de Contratos (objeto de contrato + item de contrato).
328
+ # Usada pelo módulo de CONSULTA (busca somente leitura) — não pelo pipeline
329
+ # diário de métricas. Os campos de vínculo (conta/contrato) são opcionais:
330
+ # sem eles, a consulta funciona em modo degradado (sem relacionamento).
331
+ contract_source: dict[str, Any] = field(default_factory=dict)
332
 
333
  # ------------------------------------------------------------------
334
  # Validações
 
554
  }
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
565
+ por padrão — a consulta funciona em modo degradado (sem relacionamento
566
+ de conta/itens) até que sejam configurados via ``.env`` ou pelo painel.
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"),
573
+ "item_object": _get_str("SF_CONTRACT_ITEM_OBJECT", "Item_do_Contrato__c"),
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
+
583
  @lru_cache(maxsize=1)
584
  def get_settings() -> Settings:
585
  """Constrói (uma única vez) e retorna as configurações do agente.
 
690
  ],
691
  satisfaction_source=_carregar_fonte_satisfacao(),
692
  cancellation_source=_carregar_fonte_cancelamento(),
693
+ contract_source=_carregar_fonte_contrato(),
694
  )
src/database/migrations.py CHANGED
@@ -108,6 +108,23 @@ _DDL: list[str] = [
108
  updated_at TEXT
109
  );
110
  """,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  ]
112
 
113
  # Índices para acelerar buscas históricas (comparação dia anterior / 7 dias).
@@ -122,6 +139,8 @@ _INDICES: list[str] = [
122
  "ON salesforce_snapshots (snapshot_date, object_name);",
123
  "CREATE INDEX IF NOT EXISTS idx_agent_runs_date "
124
  "ON agent_runs (run_date, status);",
 
 
125
  ]
126
 
127
 
 
108
  updated_at TEXT
109
  );
110
  """,
111
+ # 9.8 — Cache de busca (mirror somente leitura para o módulo de Consulta).
112
+ # Guarda um "espelho" de Account/Opportunity/Contrato/Item de Contrato,
113
+ # sincronizado incrementalmente pelo pipeline diário. É a camada RÁPIDA
114
+ # da busca híbrida; quando não encontra ou o dado precisa estar fresco,
115
+ # a consulta cai para o Salesforce ao vivo (ver src/query/search_service.py).
116
+ """
117
+ CREATE TABLE IF NOT EXISTS search_cache (
118
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
119
+ object_name TEXT NOT NULL,
120
+ record_id TEXT NOT NULL,
121
+ display_name TEXT,
122
+ subtitle TEXT,
123
+ payload_json TEXT NOT NULL,
124
+ synced_at TEXT NOT NULL,
125
+ UNIQUE(object_name, record_id)
126
+ );
127
+ """,
128
  ]
129
 
130
  # Índices para acelerar buscas históricas (comparação dia anterior / 7 dias).
 
139
  "ON salesforce_snapshots (snapshot_date, object_name);",
140
  "CREATE INDEX IF NOT EXISTS idx_agent_runs_date "
141
  "ON agent_runs (run_date, status);",
142
+ "CREATE INDEX IF NOT EXISTS idx_search_cache_lookup "
143
+ "ON search_cache (object_name, display_name);",
144
  ]
145
 
146
 
src/database/repositories.py CHANGED
@@ -546,3 +546,118 @@ class ObjectMappingRepository:
546
  ),
547
  )
548
  logger.info("Mapeamento salvo para domínio %s (objeto=%s).", domain, salesforce_object)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  ),
547
  )
548
  logger.info("Mapeamento salvo para domínio %s (objeto=%s).", domain, salesforce_object)
549
+
550
+
551
+ # ----------------------------------------------------------------------
552
+ # search_cache
553
+ # ----------------------------------------------------------------------
554
+ class SearchCacheRepository:
555
+ """Operações sobre a tabela ``search_cache``.
556
+
557
+ É a camada RÁPIDA do módulo de Consulta (busca híbrida): um espelho
558
+ somente leitura de Account/Opportunity/Contrato/Item de Contrato,
559
+ sincronizado incrementalmente (ver ``src/salesforce/search_sync.py``).
560
+ Quando a busca não encontra nada aqui (ou o dado precisa estar fresco),
561
+ o serviço de consulta cai para o Salesforce ao vivo.
562
+ """
563
+
564
+ def __init__(self, client: TursoClient) -> None:
565
+ self._client = client
566
+
567
+ def upsert_registros(
568
+ self,
569
+ object_name: str,
570
+ registros: list[dict[str, Any]],
571
+ campo_nome: str = "Name",
572
+ campo_subtitulo: str | None = None,
573
+ ) -> int:
574
+ """Insere ou atualiza um lote de registros no cache de busca.
575
+
576
+ Args:
577
+ object_name: Nome da API do objeto (ex.: ``"Account"``).
578
+ registros: Lista de registros (dicionários vindos do Salesforce,
579
+ já sem o atributo ``attributes``).
580
+ campo_nome: Campo usado como nome de exibição (padrão ``Name``).
581
+ campo_subtitulo: Campo opcional usado como subtítulo (ex.: um
582
+ valor monetário ou o nome da conta relacionada).
583
+
584
+ Returns:
585
+ Quantidade de registros gravados.
586
+ """
587
+ if not registros:
588
+ return 0
589
+ agora = _agora_iso()
590
+ linhas = [
591
+ (
592
+ object_name,
593
+ str(reg.get("Id", "")),
594
+ str(reg.get(campo_nome) or reg.get("Id") or ""),
595
+ str(reg.get(campo_subtitulo)) if campo_subtitulo and reg.get(campo_subtitulo) is not None else None,
596
+ json.dumps(reg, ensure_ascii=False, default=str),
597
+ agora,
598
+ )
599
+ for reg in registros
600
+ if reg.get("Id")
601
+ ]
602
+ if not linhas:
603
+ return 0
604
+ self._client.execute_many(
605
+ """
606
+ INSERT INTO search_cache
607
+ (object_name, record_id, display_name, subtitle, payload_json, synced_at)
608
+ VALUES (?, ?, ?, ?, ?, ?)
609
+ ON CONFLICT(object_name, record_id) DO UPDATE SET
610
+ display_name = excluded.display_name,
611
+ subtitle = excluded.subtitle,
612
+ payload_json = excluded.payload_json,
613
+ synced_at = excluded.synced_at
614
+ """,
615
+ linhas,
616
+ )
617
+ logger.info("Cache de busca atualizado: %d registro(s) de %s.", len(linhas), object_name)
618
+ return len(linhas)
619
+
620
+ def buscar(self, object_name: str, termo: str, limite: int = 20) -> list[dict[str, Any]]:
621
+ """Busca no cache por trecho do nome de exibição (case-insensitive).
622
+
623
+ Returns:
624
+ Lista de dicionários ``{id, name, subtitle}`` (sem o payload
625
+ completo — use :meth:`buscar_por_id` para o detalhe).
626
+ """
627
+ termo_like = f"%{termo.strip()}%"
628
+ linhas = self._client.fetch_all(
629
+ """
630
+ SELECT record_id, display_name, subtitle
631
+ FROM search_cache
632
+ WHERE object_name = ? AND display_name LIKE ? COLLATE NOCASE
633
+ ORDER BY display_name
634
+ LIMIT ?
635
+ """,
636
+ (object_name, termo_like, int(limite)),
637
+ )
638
+ return [
639
+ {"id": l["record_id"], "name": l["display_name"], "subtitle": l.get("subtitle")}
640
+ for l in linhas
641
+ ]
642
+
643
+ def buscar_por_id(self, object_name: str, record_id: str) -> dict[str, Any] | None:
644
+ """Retorna o payload completo cacheado de um registro (ou None)."""
645
+ linha = self._client.fetch_one(
646
+ "SELECT payload_json, synced_at FROM search_cache WHERE object_name = ? AND record_id = ?",
647
+ (object_name, record_id),
648
+ )
649
+ if not linha:
650
+ return None
651
+ try:
652
+ payload = json.loads(linha["payload_json"])
653
+ except (json.JSONDecodeError, TypeError):
654
+ return None
655
+ return {"payload": payload, "synced_at": linha.get("synced_at")}
656
+
657
+ def contar(self, object_name: str) -> int:
658
+ """Quantidade de registros cacheados de um objeto."""
659
+ linha = self._client.fetch_one(
660
+ "SELECT COUNT(*) AS total FROM search_cache WHERE object_name = ?",
661
+ (object_name,),
662
+ )
663
+ return int(linha["total"]) if linha else 0
src/delivery/email_sender.py CHANGED
@@ -22,8 +22,31 @@ from ..utils.logger import get_logger
22
 
23
  logger = get_logger("delivery.email")
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  # Cores por severidade (usadas em selos/badges no e-mail HTML).
26
- _COR_SEVERIDADE = {"high": "#c0392b", "medium": "#e67e22", "low": "#f1c40f"}
 
 
27
  _ROTULO_SEVERIDADE = {"high": "ALTA", "medium": "MÉDIA", "low": "BAIXA"}
28
 
29
 
@@ -128,7 +151,7 @@ def _md_para_html(texto: str) -> str:
128
  # ----------------------------------------------------------------------
129
  # Blocos do e-mail HTML
130
  # ----------------------------------------------------------------------
131
- def _linha_kpi(rotulo: str, valor: str, destaque: str = "#1f4fb2") -> str:
132
  """Célula de KPI (rótulo + valor) para a grade de números-chave."""
133
  return (
134
  '<td style="padding:10px 14px;border:1px solid #e6e8eb;'
@@ -155,16 +178,16 @@ def _grade_kpis(metrics: dict[str, Any]) -> str:
155
  "Pipeline aberto",
156
  _moeda(opp.get("open_pipeline_product_value") or opp.get("open_pipeline_amount")),
157
  ),
158
- _linha_kpi("Oportunidades ganhas", _num(opp.get("won_opportunities")), "#15803d"),
159
- _linha_kpi("Oportunidades perdidas", _num(opp.get("lost_opportunities")), "#b91c1c"),
160
- _linha_kpi("Oportunidades paradas", _num(opp.get("stalled_opportunities")), "#b45309"),
161
- _linha_kpi("Tarefas vencidas", _num(tasks.get("tasks_overdue")), "#b45309"),
162
  ]
163
  if sat.get("configured") and sat.get("responses"):
164
  celulas.append(_linha_kpi("Satisfação (nota média)", _num(sat.get("avg_score"))))
165
  if canc.get("configured") and canc.get("cancellations_count"):
166
  celulas.append(
167
- _linha_kpi("Cancelamentos", _num(canc.get("cancellations_count")), "#b91c1c")
168
  )
169
 
170
  # Distribui as células em linhas de 3 colunas.
@@ -199,6 +222,7 @@ def _bloco_alertas(alerts: list[dict[str, Any]]) -> str:
199
  for a in selecionados:
200
  sev = a.get("severity", "low")
201
  cor = _COR_SEVERIDADE.get(sev, "#94a3b8")
 
202
  rotulo = _ROTULO_SEVERIDADE.get(sev, "INFO")
203
  acao = a.get("recommended_action")
204
  acao_html = (
@@ -208,9 +232,9 @@ def _bloco_alertas(alerts: list[dict[str, Any]]) -> str:
208
  else ""
209
  )
210
  cartoes.append(
211
- f'<div style="border-left:4px solid {cor};background:#f8fafc;'
212
  f'padding:10px 14px;margin-bottom:10px;border-radius:0 8px 8px 0;">'
213
- f'<span style="display:inline-block;background:{cor};color:#fff;'
214
  f'font-size:10px;font-weight:700;padding:2px 8px;border-radius:10px;'
215
  f'letter-spacing:.04em;">{rotulo}</span> '
216
  f'<span style="font-weight:700;color:#0f172a;font-size:14px;">'
@@ -256,36 +280,59 @@ def _montar_html(
256
  corpo_ia = _md_para_html(resumo_ia) if resumo_ia.strip() else ""
257
  if corpo_ia:
258
  bloco_ia = (
259
- "<div style='background:#eef4ff;border:1px solid #dbe5fb;border-left:4px solid #1f4fb2;"
260
  "border-radius:10px;padding:14px 16px;margin:0 0 18px;'>"
261
- "<div style='font-size:12px;color:#1f4fb2;font-weight:700;text-transform:uppercase;"
262
  "letter-spacing:.04em;margin-bottom:8px;'>🧠 Análise do dia</div>"
263
  f"{corpo_ia}"
264
  "</div>"
265
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  return f"""\
267
  <div style="background:#f1f5f9;padding:24px 0;font-family:Arial,Helvetica,sans-serif;">
268
  <table role="presentation" width="640" align="center" cellspacing="0" cellpadding="0"
269
  style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;
270
- overflow:hidden;border:1px solid #e2e8f0;">
271
  <tr>
272
- <td style="background:linear-gradient(120deg,#0b3d91,#1f4fb2 58%,#3b82f6);padding:26px 28px;">
273
- <div style="color:#ffffff;font-size:22px;font-weight:800;letter-spacing:.2px;">📊 Analytical-Force</div>
274
- <div style="color:#dbe4ff;font-size:13px;margin-top:4px;">Relatório diário de performance comercial</div>
275
- <span style="display:inline-block;margin-top:12px;background:rgba(255,255,255,.18);color:#ffffff;
276
- font-size:12px;font-weight:600;padding:5px 12px;border-radius:999px;">📅 {_escape(report_date)}</span>
277
- </td>
278
  </tr>
279
  <tr>
280
  <td style="padding:24px 28px;">
281
- <h3 style="font-size:14px;color:#0f172a;margin:0 0 10px;padding-left:10px;border-left:3px solid #1f4fb2;">Resumo executivo</h3>
282
  <p style="font-size:14px;color:#475569;margin:0 0 14px;line-height:1.55;">{resumo}</p>
283
  {bloco_ia}
284
 
285
- <h3 style="font-size:14px;color:#0f172a;margin:22px 0 10px;padding-left:10px;border-left:3px solid #1f4fb2;">Números-chave</h3>
286
  {_grade_kpis(metrics)}
287
 
288
- <h3 style="font-size:14px;color:#0f172a;margin:22px 0 10px;padding-left:10px;border-left:3px solid #dc2626;">Principais alertas</h3>
289
  {_bloco_alertas(alerts)}
290
 
291
  {_bloco_prioridades(alerts)}
@@ -294,13 +341,7 @@ def _montar_html(
294
  </td>
295
  </tr>
296
  <tr>
297
- <td style="background:#f8fafc;padding:16px 28px;border-top:1px solid #e2e8f0;">
298
- <div style="font-size:12px;color:#94a3b8;">
299
- Relatório gerado automaticamente pelo agente <strong>Analytical-Force</strong>.
300
- Os números são calculados em Python a partir do Salesforce; a interpretação
301
- é gerada por modelo local. O relatório completo está anexado/registrado no sistema.
302
- </div>
303
- </td>
304
  </tr>
305
  </table>
306
  </div>"""
@@ -369,6 +410,7 @@ _ROTULO_DESTAQUE = {
369
  "leads_sem_tarefa": "⏳ Leads sem 1ª tarefa",
370
  "oportunidades_travadas": "🚧 Oportunidades travadas",
371
  "oportunidades_ganhas": "🏆 Oportunidades ganhas",
 
372
  "cancelamentos": "❌ Cancelamentos",
373
  "satisfacoes_piores": "😟 Piores satisfações",
374
  }
 
22
 
23
  logger = get_logger("delivery.email")
24
 
25
+ # ----------------------------------------------------------------------
26
+ # Identidade visual (Penso) — paleta oficial da marca.
27
+ # ----------------------------------------------------------------------
28
+ # Cores "vivas" (uso em fundos/selos) e variantes escurecidas para texto
29
+ # sobre fundo branco (mantém contraste de leitura sem perder a identidade).
30
+ _PENSO_BLUE = "#0018FF"
31
+ _BLUE_TECH = "#0004DD"
32
+ _PENSO_BLACK = "#171717"
33
+ _BLACK_INTENSE = "#060606"
34
+ _SUCESSO = "#16C79A"
35
+ _SUCESSO_TXT = "#0d8a6d"
36
+ _ERRO = "#F05454"
37
+ _ERRO_TXT = "#c62f2f"
38
+ _ALERTA = "#FFC453"
39
+ _ALERTA_TXT = "#a15c00"
40
+ _DIVISORIA = "#E6EAEE"
41
+ _HOVER = "#F7F8FA"
42
+
43
+ # Logo do Salesforce (fonte de dados) — mesma URL pública já usada no painel React.
44
+ _LOGO_SALESFORCE_URL = "https://upload.wikimedia.org/wikipedia/commons/f/f9/Salesforce.com_logo.svg"
45
+
46
  # Cores por severidade (usadas em selos/badges no e-mail HTML).
47
+ _COR_SEVERIDADE = {"high": _ERRO, "medium": _ALERTA, "low": "#94a3b8"}
48
+ # Texto do selo: escuro sobre o Alerta (amarelo) para manter contraste; branco nos demais.
49
+ _TEXTO_SEVERIDADE = {"high": "#ffffff", "medium": _PENSO_BLACK, "low": "#ffffff"}
50
  _ROTULO_SEVERIDADE = {"high": "ALTA", "medium": "MÉDIA", "low": "BAIXA"}
51
 
52
 
 
151
  # ----------------------------------------------------------------------
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;'
 
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"))))
188
  if canc.get("configured") and canc.get("cancellations_count"):
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.
 
222
  for a in selecionados:
223
  sev = a.get("severity", "low")
224
  cor = _COR_SEVERIDADE.get(sev, "#94a3b8")
225
+ cor_texto_selo = _TEXTO_SEVERIDADE.get(sev, "#ffffff")
226
  rotulo = _ROTULO_SEVERIDADE.get(sev, "INFO")
227
  acao = a.get("recommended_action")
228
  acao_html = (
 
232
  else ""
233
  )
234
  cartoes.append(
235
+ f'<div style="border-left:4px solid {cor};background:{_HOVER};'
236
  f'padding:10px 14px;margin-bottom:10px;border-radius:0 8px 8px 0;">'
237
+ f'<span style="display:inline-block;background:{cor};color:{cor_texto_selo};'
238
  f'font-size:10px;font-weight:700;padding:2px 8px;border-radius:10px;'
239
  f'letter-spacing:.04em;">{rotulo}</span> '
240
  f'<span style="font-weight:700;color:#0f172a;font-size:14px;">'
 
280
  corpo_ia = _md_para_html(resumo_ia) if resumo_ia.strip() else ""
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}"
288
  "</div>"
289
  )
290
+ # Cabeçalho: identidade visual da Penso (marca-texto) + Analytical-Force.
291
+ # TODO: quando o arquivo do logo real da Penso estiver disponível, trocar
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>
299
+ <td style="padding-left:10px;color:#ffffff;font-size:13px;font-weight:700;letter-spacing:.03em;">PENSO</td>
300
+ </tr></table>
301
+ <div style="color:#ffffff;font-size:22px;font-weight:800;letter-spacing:.2px;margin-top:12px;">📊 Analytical-Force</div>
302
+ <div style="color:#dbe4ff;font-size:13px;margin-top:4px;">Relatório diário de performance comercial</div>
303
+ <span style="display:inline-block;margin-top:12px;background:rgba(255,255,255,.18);color:#ffffff;
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}
331
 
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
 
338
  {_bloco_prioridades(alerts)}
 
341
  </td>
342
  </tr>
343
  <tr>
344
+ {rodape}
 
 
 
 
 
 
345
  </tr>
346
  </table>
347
  </div>"""
 
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
  }
src/query/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Camada de Consulta (busca somente leitura) do Analytical-Force.
2
+
3
+ Diferente de ``analytics`` (que calcula métricas do pipeline diário), este
4
+ pacote orquestra a busca/detalhe sob demanda de Contas, Oportunidades,
5
+ Contratos e Itens de Contrato para o painel — usada pela tela de "Busca".
6
+ """
7
+
8
+ from .search_service import TIPOS_VALIDOS, buscar, detalhar, status_tipos
9
+
10
+ __all__ = ["TIPOS_VALIDOS", "buscar", "detalhar", "status_tipos"]
src/query/search_service.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Serviço de Consulta (busca híbrida) — camada de orquestração.
2
+
3
+ Não calcula métricas nem gera relatório: apenas orquestra a busca de
4
+ Account, Opportunity, Contrato e Item de Contrato para o painel, seguindo a
5
+ estratégia HÍBRIDA definida para o projeto:
6
+
7
+ 1. Busca primeiro no cache do Turso (``search_cache``) — rápido.
8
+ 2. Se não encontrar nada, cai para consulta ao vivo no Salesforce (somente
9
+ leitura) e, quando encontra algo, grava o resultado no cache para
10
+ acelerar a próxima busca pelo mesmo termo.
11
+
12
+ Esta é a ÚNICA camada que conhece o mapeamento "tipo lógico -> objeto do
13
+ Salesforce". A API HTTP (``api.py``) nunca deve aceitar um nome de objeto
14
+ arbitrário vindo do cliente — apenas um dos tipos em :data:`TIPOS_VALIDOS`,
15
+ o que evita qualquer risco de injeção de SOQL pela camada web.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from ..config.settings import Settings
23
+ from ..database.repositories import SearchCacheRepository
24
+ from ..database.turso_client import TursoClient
25
+ from ..salesforce import search as sf_search
26
+ from ..salesforce.client import SalesforceAuthError, SalesforceClient
27
+ from ..utils.logger import get_logger
28
+
29
+ logger = get_logger("query.search_service")
30
+
31
+ # Tipos lógicos aceitos pela API — único ponto que traduz para o objeto real
32
+ # do Salesforce. Nunca exponha nomes de objeto direto na API HTTP.
33
+ TIPOS_VALIDOS: tuple[str, ...] = ("conta", "oportunidade", "contrato", "item")
34
+
35
+ _ROTULOS = {
36
+ "conta": "Clientes (Account)",
37
+ "oportunidade": "Oportunidades",
38
+ "contrato": "Contratos",
39
+ "item": "Itens de contrato",
40
+ }
41
+
42
+ _TAMANHO_MINIMO_TERMO = 2
43
+
44
+
45
+ def _mapa_objetos(settings: Settings) -> dict[str, str]:
46
+ """Mapa tipo lógico -> nome de API do objeto Salesforce."""
47
+ contrato = settings.contract_source or {}
48
+ return {
49
+ "conta": "Account",
50
+ "oportunidade": "Opportunity",
51
+ "contrato": contrato.get("object") or "Contrato_oPT__c",
52
+ "item": contrato.get("item_object") or "Item_do_Contrato__c",
53
+ }
54
+
55
+
56
+ def status_tipos(settings: Settings) -> dict[str, Any]:
57
+ """Retorna os tipos de busca disponíveis e o estado da configuração.
58
+
59
+ Usado pela tela de Busca para exibir avisos (ex.: "vínculo com conta
60
+ não configurado") sem travar a funcionalidade principal.
61
+ """
62
+ contrato = settings.contract_source or {}
63
+ mapa = _mapa_objetos(settings)
64
+ return {
65
+ "tipos": [
66
+ {"tipo": tipo, "objeto": objeto, "rotulo": _ROTULOS.get(tipo, tipo)}
67
+ for tipo, objeto in mapa.items()
68
+ ],
69
+ "conta_do_contrato_configurada": bool(contrato.get("account_field")),
70
+ "itens_do_contrato_configurados": bool(contrato.get("item_parent_field")),
71
+ }
72
+
73
+
74
+ def buscar(
75
+ settings: Settings,
76
+ turso: TursoClient,
77
+ sf_client: SalesforceClient,
78
+ termo: str,
79
+ tipos: list[str] | None = None,
80
+ limite: int = 20,
81
+ ) -> dict[str, list[dict[str, Any]]]:
82
+ """Busca híbrida: cache do Turso primeiro, Salesforce ao vivo como fallback.
83
+
84
+ Args:
85
+ termo: Texto buscado (mínimo 2 caracteres — evita varreduras amplas).
86
+ tipos: Subconjunto de :data:`TIPOS_VALIDOS` (``None`` = todos).
87
+ limite: Máximo de resultados por tipo.
88
+
89
+ Returns:
90
+ Dicionário ``{tipo: [{id, name, subtitle}, ...]}`` — tipos sem
91
+ resultado não aparecem na resposta.
92
+ """
93
+ termo = (termo or "").strip()
94
+ if len(termo) < _TAMANHO_MINIMO_TERMO:
95
+ return {}
96
+
97
+ mapa = _mapa_objetos(settings)
98
+ tipos_busca = [t for t in (tipos or TIPOS_VALIDOS) if t in mapa]
99
+ cache = SearchCacheRepository(turso)
100
+ resultado: dict[str, list[dict[str, Any]]] = {}
101
+
102
+ for tipo in tipos_busca:
103
+ objeto = mapa[tipo]
104
+ achados: list[dict[str, Any]] = []
105
+ try:
106
+ achados = cache.buscar(objeto, termo, limite)
107
+ except Exception as exc: # cache não deve derrubar a busca
108
+ logger.warning("Falha ao consultar cache de %s: %s", objeto, type(exc).__name__)
109
+
110
+ if not achados:
111
+ # Fallback: Salesforce ao vivo (somente leitura).
112
+ try:
113
+ registros = sf_search.buscar_por_nome(sf_client, objeto, termo, limite)
114
+ achados = [
115
+ {"id": r.get("Id"), "name": r.get("Name") or r.get("Id"), "subtitle": None}
116
+ for r in registros
117
+ ]
118
+ if registros:
119
+ # Alimenta o cache com o achado ao vivo (acelera a próxima busca).
120
+ try:
121
+ cache.upsert_registros(objeto, registros)
122
+ except Exception as exc:
123
+ logger.warning(
124
+ "Falha ao alimentar cache de %s: %s", objeto, type(exc).__name__
125
+ )
126
+ except SalesforceAuthError as exc:
127
+ logger.warning("Busca ao vivo falhou em %s: %s", objeto, exc)
128
+
129
+ if achados:
130
+ resultado[tipo] = achados
131
+
132
+ return resultado
133
+
134
+
135
+ def detalhar(
136
+ settings: Settings,
137
+ turso: TursoClient,
138
+ sf_client: SalesforceClient,
139
+ tipo: str,
140
+ record_id: str,
141
+ ) -> dict[str, Any]:
142
+ """Detalhe completo de um registro (ao vivo; cache como último recurso).
143
+
144
+ Prioriza a consulta ao vivo para o DETALHE (mais sensível a estar
145
+ desatualizado do que a lista de busca); só usa o cache se o Salesforce
146
+ falhar (ex.: instabilidade momentânea da API).
147
+
148
+ Args:
149
+ tipo: Um dos :data:`TIPOS_VALIDOS`.
150
+ record_id: Id do registro no Salesforce.
151
+
152
+ Returns:
153
+ Dicionário com ``tipo``, ``objeto``, ``origem`` (salesforce/cache),
154
+ ``campos`` (lista ``{campo, valor}``) e, para contratos,
155
+ ``itens_do_contrato`` quando o vínculo estiver configurado.
156
+
157
+ Raises:
158
+ ValueError: Tipo inválido ou registro não encontrado em nenhuma fonte.
159
+ """
160
+ mapa = _mapa_objetos(settings)
161
+ if tipo not in mapa:
162
+ raise ValueError(f"Tipo de consulta inválido: {tipo!r}")
163
+ objeto = mapa[tipo]
164
+ cache = SearchCacheRepository(turso)
165
+
166
+ payload: dict[str, Any] | None = None
167
+ origem = "salesforce"
168
+ try:
169
+ payload = sf_search.detalhar_registro(sf_client, objeto, record_id)
170
+ if payload:
171
+ try:
172
+ cache.upsert_registros(objeto, [payload])
173
+ except Exception as exc:
174
+ logger.warning("Falha ao atualizar cache de %s: %s", objeto, type(exc).__name__)
175
+ except SalesforceAuthError as exc:
176
+ logger.warning("Detalhe ao vivo falhou em %s/%s: %s", objeto, record_id, exc)
177
+
178
+ if not payload:
179
+ cacheado = cache.buscar_por_id(objeto, record_id)
180
+ if cacheado:
181
+ payload = cacheado["payload"]
182
+ origem = f"cache (sincronizado em {cacheado.get('synced_at')})"
183
+
184
+ if not payload:
185
+ raise ValueError(f"Registro não encontrado: {objeto}/{record_id}")
186
+
187
+ resultado: dict[str, Any] = {
188
+ "tipo": tipo,
189
+ "objeto": objeto,
190
+ "origem": origem,
191
+ "campos": [
192
+ {"campo": campo, "valor": valor}
193
+ for campo, valor in payload.items()
194
+ if campo != "attributes" and valor is not None
195
+ ],
196
+ }
197
+
198
+ # Contrato -> lista os itens vinculados, se o campo de vínculo estiver configurado.
199
+ if tipo == "contrato":
200
+ contrato_cfg = settings.contract_source or {}
201
+ campo_pai = contrato_cfg.get("item_parent_field")
202
+ if campo_pai:
203
+ try:
204
+ itens = sf_search.listar_relacionados(sf_client, mapa["item"], campo_pai, record_id)
205
+ resultado["itens_do_contrato"] = itens
206
+ except SalesforceAuthError as exc:
207
+ logger.warning("Falha ao listar itens do contrato: %s", exc)
208
+ resultado["itens_do_contrato"] = []
209
+ else:
210
+ resultado["itens_do_contrato"] = None # vínculo não configurado
211
+
212
+ return resultado
src/salesforce/search.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Consulta ao vivo (SOQL) para o módulo de Busca — somente leitura.
2
+
3
+ Não participa do pipeline diário de métricas: é usado sob demanda pelo
4
+ serviço de consulta (``src/query/search_service.py``) quando o cache do
5
+ Turso não tem o registro procurado, ou quando o dado precisa estar fresco.
6
+
7
+ Regra 3 do projeto (nunca inventar campos): a busca usa apenas ``Id``/``Name``
8
+ (sempre presentes em qualquer objeto do Salesforce, padrão ou customizado) e
9
+ o detalhe usa ``FIELDS(ALL)`` — os campos exibidos vêm do schema real da
10
+ organização (descoberto em tempo de execução), nunca fixados no código.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any
17
+
18
+ from .client import SalesforceAuthError, SalesforceClient
19
+ from ..utils.logger import get_logger
20
+
21
+ logger = get_logger("salesforce.search")
22
+
23
+ # Formato de Id do Salesforce (15 ou 18 caracteres alfanuméricos).
24
+ _ID_VALIDO = re.compile(r"^[a-zA-Z0-9]{15,18}$")
25
+
26
+ # Nome de objeto/campo de API do Salesforce: letras, números, "_" e "__c"/"__r".
27
+ _NOME_API_VALIDO = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$")
28
+
29
+ _LIMITE_MAXIMO_BUSCA = 50
30
+ _LIMITE_MAXIMO_DETALHE = 200 # teto exigido pelo SOQL ao usar FIELDS(ALL)/FIELDS(CUSTOM)
31
+
32
+
33
+ def _escapar_termo(termo: str) -> str:
34
+ """Escapa aspas simples/barras para uso seguro em cláusula LIKE do SOQL."""
35
+ return (termo or "").replace("\\", "\\\\").replace("'", "\\'")
36
+
37
+
38
+ def _validar_nome_api(nome: str, rotulo: str) -> str:
39
+ """Garante que ``nome`` é um identificador de API válido (objeto/campo).
40
+
41
+ Barreira contra injeção de SOQL nos poucos lugares em que o nome do
42
+ objeto/campo é interpolado diretamente na consulta (esses nomes vêm de
43
+ configuração interna — nunca de entrada livre do usuário).
44
+ """
45
+ if not nome or not _NOME_API_VALIDO.match(nome):
46
+ raise SalesforceAuthError(f"{rotulo} inválido para consulta: {nome!r}")
47
+ return nome
48
+
49
+
50
+ def _validar_id(record_id: str) -> str:
51
+ """Garante que ``record_id`` tem o formato de um Id do Salesforce."""
52
+ valor = (record_id or "").strip()
53
+ if not _ID_VALIDO.match(valor):
54
+ raise SalesforceAuthError("Id inválido para consulta.")
55
+ return valor
56
+
57
+
58
+ def buscar_por_nome(
59
+ client: SalesforceClient, objeto: str, termo: str, limite: int = 20
60
+ ) -> list[dict[str, Any]]:
61
+ """Busca registros de um objeto por trecho do campo ``Name`` (ao vivo).
62
+
63
+ Args:
64
+ client: Cliente Salesforce autenticado (somente leitura).
65
+ objeto: Nome de API do objeto (ex.: ``"Account"``, ``"Opportunity"``).
66
+ termo: Trecho buscado (aplica-se ``LIKE '%termo%'``).
67
+ limite: Máximo de registros retornados (até 50).
68
+
69
+ Returns:
70
+ Lista de registros (``Id`` e ``Name``). Vazia em caso de falha —
71
+ busca é uma funcionalidade auxiliar e não deve derrubar a tela.
72
+ """
73
+ _validar_nome_api(objeto, "Objeto")
74
+ termo_esc = _escapar_termo(termo)
75
+ limite = max(1, min(int(limite), _LIMITE_MAXIMO_BUSCA))
76
+ soql = f"SELECT Id, Name FROM {objeto} WHERE Name LIKE '%{termo_esc}%' LIMIT {limite}"
77
+ try:
78
+ return client.query(soql)
79
+ except SalesforceAuthError as exc:
80
+ logger.warning("Busca ao vivo falhou em %s: %s", objeto, exc)
81
+ return []
82
+
83
+
84
+ def detalhar_registro(
85
+ client: SalesforceClient, objeto: str, record_id: str
86
+ ) -> dict[str, Any] | None:
87
+ """Retorna TODOS os campos populados de um registro (ao vivo).
88
+
89
+ Usa ``FIELDS(ALL)`` (SOQL, disponível a partir da API v58.0) — os campos
90
+ exibidos vêm do schema real da organização, nunca fixados no código.
91
+
92
+ Args:
93
+ client: Cliente Salesforce autenticado (somente leitura).
94
+ objeto: Nome de API do objeto.
95
+ record_id: Id do registro (validado antes de entrar na consulta).
96
+
97
+ Returns:
98
+ Dicionário de campos do registro, ou ``None`` se não encontrado.
99
+ """
100
+ _validar_nome_api(objeto, "Objeto")
101
+ rid = _validar_id(record_id)
102
+ soql = f"SELECT FIELDS(ALL) FROM {objeto} WHERE Id = '{rid}' LIMIT 1"
103
+ registros = client.query(soql)
104
+ return registros[0] if registros else None
105
+
106
+
107
+ def listar_relacionados(
108
+ client: SalesforceClient,
109
+ objeto_filho: str,
110
+ campo_pai: str,
111
+ id_pai: str,
112
+ limite: int = 200,
113
+ ) -> list[dict[str, Any]]:
114
+ """Lista registros filhos vinculados a um pai por um campo de lookup.
115
+
116
+ Usado, por exemplo, para listar os Itens de um Contrato quando o campo
117
+ de vínculo (``SF_CONTRACT_ITEM_PARENT_FIELD``) estiver configurado.
118
+
119
+ Args:
120
+ objeto_filho: Nome de API do objeto filho (ex.: ``"Item_do_Contrato__c"``).
121
+ campo_pai: Nome de API do campo de lookup que aponta para o pai.
122
+ id_pai: Id do registro pai.
123
+ limite: Máximo de registros retornados (até 200, teto do FIELDS(ALL)).
124
+ """
125
+ _validar_nome_api(objeto_filho, "Objeto filho")
126
+ _validar_nome_api(campo_pai, "Campo de vínculo")
127
+ rid = _validar_id(id_pai)
128
+ limite = max(1, min(int(limite), _LIMITE_MAXIMO_DETALHE))
129
+ soql = f"SELECT FIELDS(ALL) FROM {objeto_filho} WHERE {campo_pai} = '{rid}' LIMIT {limite}"
130
+ return client.query(soql)
src/salesforce/search_sync.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sincronização incremental do cache de busca (módulo de Consulta).
2
+
3
+ Roda como etapa BEST-EFFORT do pipeline diário (chamada por
4
+ ``AnalyticalForceAgent.executar``): uma falha aqui nunca deve derrubar a
5
+ execução principal. Usa ``LastModifiedDate`` para buscar apenas o que mudou
6
+ desde a última sincronização (guardada em ``agent_config`` via
7
+ ``ConfigRepository``), paginando por ``OFFSET`` respeitando o limite de 200
8
+ registros por página exigido pelo ``FIELDS(ALL)``.
9
+
10
+ É a camada de escrita da busca HÍBRIDA escolhida para o projeto: o cache
11
+ (Turso) responde rápido; quando não encontra, ``src/query/search_service.py``
12
+ cai para consulta ao vivo no Salesforce.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from ..config.settings import Settings
20
+ from ..database.repositories import ConfigRepository, SearchCacheRepository
21
+ from ..salesforce.client import SalesforceAuthError, SalesforceClient
22
+ from ..utils.date_utils import agora_tz
23
+ from ..utils.logger import get_logger
24
+
25
+ logger = get_logger("salesforce.search_sync")
26
+
27
+ _LOTE = 200 # teto de registros por página exigido pelo FIELDS(ALL)
28
+ _MAX_REGISTROS_POR_OBJETO = 2000 # teto de segurança por execução/objeto
29
+ _CHAVE_ULTIMA_SYNC = "search_sync_last_{objeto}"
30
+ _DESDE_PADRAO = "2000-01-01T00:00:00Z" # primeira sincronização: busca tudo (até o teto)
31
+
32
+
33
+ def _consultar_alterados(
34
+ client: SalesforceClient, objeto: str, desde_iso: str
35
+ ) -> list[dict[str, Any]]:
36
+ """Busca (com paginação) os registros de ``objeto`` alterados desde ``desde_iso``."""
37
+ registros: list[dict[str, Any]] = []
38
+ offset = 0
39
+ while offset < _MAX_REGISTROS_POR_OBJETO:
40
+ soql = (
41
+ f"SELECT FIELDS(ALL) FROM {objeto} "
42
+ f"WHERE LastModifiedDate >= {desde_iso} "
43
+ f"ORDER BY LastModifiedDate ASC LIMIT {_LOTE} OFFSET {offset}"
44
+ )
45
+ try:
46
+ pagina = client.query(soql)
47
+ except SalesforceAuthError as exc:
48
+ # Objeto pode não existir na org (ex.: contrato customizado não
49
+ # aplicável) — registra e segue sem quebrar os demais objetos.
50
+ logger.warning("Sincronização de %s interrompida: %s", objeto, exc)
51
+ break
52
+ if not pagina:
53
+ break
54
+ registros.extend(pagina)
55
+ if len(pagina) < _LOTE:
56
+ break
57
+ offset += _LOTE
58
+ else:
59
+ logger.warning(
60
+ "Sincronização de %s atingiu o teto de segurança (%d); pode haver "
61
+ "registros pendentes para o próximo ciclo.",
62
+ objeto,
63
+ _MAX_REGISTROS_POR_OBJETO,
64
+ )
65
+ return registros
66
+
67
+
68
+ def sincronizar_cache_busca(
69
+ client: SalesforceClient,
70
+ settings: Settings,
71
+ config_repo: ConfigRepository,
72
+ cache_repo: SearchCacheRepository,
73
+ ) -> dict[str, int]:
74
+ """Sincroniza incrementalmente Account/Opportunity/Contrato/Item no Turso.
75
+
76
+ Cada objeto é isolado: uma falha em um (ex.: objeto de contrato não
77
+ existe nesta org) não afeta a sincronização dos demais.
78
+
79
+ Args:
80
+ client: Cliente Salesforce já autenticado (reaproveitado da extração
81
+ principal — evita autenticar duas vezes na mesma execução).
82
+ settings: Configurações do agente (usa ``settings.contract_source``).
83
+ config_repo: Repositório de configuração (guarda o "desde quando"
84
+ de cada objeto).
85
+ cache_repo: Repositório do cache de busca (destino da gravação).
86
+
87
+ Returns:
88
+ Dicionário ``{objeto: quantidade_sincronizada}``.
89
+ """
90
+ contrato_cfg = settings.contract_source or {}
91
+ objetos: list[tuple[str, str, str | None]] = [
92
+ ("Account", "Name", None),
93
+ ("Opportunity", "Name", "StageName"),
94
+ (contrato_cfg.get("object") or "Contrato_oPT__c", "Name", None),
95
+ (contrato_cfg.get("item_object") or "Item_do_Contrato__c", "Name", None),
96
+ ]
97
+ agora_iso = agora_tz(settings.report_timezone).strftime("%Y-%m-%dT%H:%M:%SZ")
98
+ resultado: dict[str, int] = {}
99
+
100
+ for objeto, campo_nome, campo_subtitulo in objetos:
101
+ chave = _CHAVE_ULTIMA_SYNC.format(objeto=objeto)
102
+ try:
103
+ desde = config_repo.buscar_config(chave) or _DESDE_PADRAO
104
+ registros = _consultar_alterados(client, objeto, desde)
105
+ gravados = cache_repo.upsert_registros(objeto, registros, campo_nome, campo_subtitulo)
106
+ config_repo.salvar_config(
107
+ chave,
108
+ agora_iso,
109
+ descricao=f"Última sincronização do cache de busca de {objeto}.",
110
+ )
111
+ resultado[objeto] = gravados
112
+ logger.info("Cache de busca sincronizado: %s (%d registro(s)).", objeto, gravados)
113
+ except Exception as exc: # objeto isolado — não derruba os demais
114
+ logger.warning("Falha ao sincronizar cache de %s: %s", objeto, type(exc).__name__)
115
+ resultado[objeto] = 0
116
+
117
+ return resultado