Spaces:
Sleeping
Sleeping
Commit ·
bbc30f3
1
Parent(s): 197ac2a
Corrige e-mail, adiciona CC persistido e migra painel para React
Browse files- .env.example +16 -0
- api.py +86 -0
- frontend-react/src/App.jsx +2 -0
- frontend-react/src/api.js +13 -0
- frontend-react/src/components/Sidebar.jsx +4 -2
- frontend-react/src/style.css +19 -15
- frontend-react/src/views/Search.jsx +252 -0
- frontend-react/vite.config.js.timestamp-1783562543181-33acbd5d299df.mjs +15 -0
- frontend-react/vite.config.js.timestamp-1783562598611-6eea937b4fc98.mjs +15 -0
- src/agent/analytical_force_agent.py +45 -5
- src/config/settings.py +32 -0
- src/database/migrations.py +19 -0
- src/database/repositories.py +115 -0
- src/delivery/email_sender.py +70 -28
- src/query/__init__.py +10 -0
- src/query/search_service.py +212 -0
- src/salesforce/search.py +130 -0
- src/salesforce/search_sync.py +117 -0
.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 |
-
|
|
|
|
| 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 |
-
-
|
| 3 |
-
--
|
| 4 |
-
--
|
| 5 |
-
--
|
| 6 |
-
--
|
| 7 |
-
--
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
-
[data-theme="dark"]{--bg:#
|
| 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(
|
| 14 |
-
radial-gradient(900px 400px at -10% 0%,rgba(
|
| 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,#
|
| 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
|
|
|
|
| 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:#
|
| 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:#
|
| 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:#
|
| 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(
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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":
|
|
|
|
|
|
|
| 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 =
|
| 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")),
|
| 159 |
-
_linha_kpi("Oportunidades perdidas", _num(opp.get("lost_opportunities")),
|
| 160 |
-
_linha_kpi("Oportunidades paradas", _num(opp.get("stalled_opportunities")),
|
| 161 |
-
_linha_kpi("Tarefas vencidas", _num(tasks.get("tasks_overdue")),
|
| 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")),
|
| 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:
|
| 212 |
f'padding:10px 14px;margin-bottom:10px;border-radius:0 8px 8px 0;">'
|
| 213 |
-
f'<span style="display:inline-block;background:{cor};color:
|
| 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:
|
| 260 |
"border-radius:10px;padding:14px 16px;margin:0 0 18px;'>"
|
| 261 |
-
"<div style='font-size:12px;color:
|
| 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
|
| 271 |
<tr>
|
| 272 |
-
|
| 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
|
| 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
|
| 286 |
{_grade_kpis(metrics)}
|
| 287 |
|
| 288 |
-
<h3 style="font-size:14px;color:#0f172a;margin:22px 0 10px;padding-left:10px;border-left:3px solid
|
| 289 |
{_bloco_alertas(alerts)}
|
| 290 |
|
| 291 |
{_bloco_prioridades(alerts)}
|
|
@@ -294,13 +341,7 @@ def _montar_html(
|
|
| 294 |
</td>
|
| 295 |
</tr>
|
| 296 |
<tr>
|
| 297 |
-
|
| 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
|