feat: Implementa job de ranking para 300k consultores

Backend:
- Adiciona Scroll API no cliente Elasticsearch para processar todos os 300k+ consultores
- Cria tabela TB_RANKING_CONSULTOR no Oracle para ranking pré-calculado
- Implementa job de processamento com APScheduler (diário às 3h)
- Adiciona endpoints: /ranking/paginado, /ranking/status, /ranking/processar, /ranking/estatisticas
- Repository Oracle com paginação eficiente via ROW_NUMBER
- Status do job com progresso em tempo real (polling)
- Leitura automática de LOBs no OracleClient

Frontend:
- Componente RankingPaginado com paginação completa
- Barra de progresso do job em tempo real
- Botão para reprocessar ranking
- Alternância entre Top N (rápido) e Ranking Completo (300k)

Infraestrutura:
- Docker compose com depends_on para garantir Oracle disponível
- Schema SQL com procedure SP_ATUALIZAR_POSICOES
- Índices otimizados para paginação
This commit is contained in:
Frederico Castro
2025-12-10 01:33:00 -03:00
parent 0213a55791
commit 3ea6a4409e
19 changed files with 1596 additions and 20 deletions

View File

@@ -46,6 +46,36 @@
background: var(--accent-2);
}
.mode-selector {
display: flex;
gap: 0.5rem;
margin: 1.5rem 0;
justify-content: center;
}
.mode-selector button {
padding: 0.75rem 1.5rem;
background: rgba(255,255,255,0.06);
border: 1px solid var(--stroke);
border-radius: 8px;
color: var(--muted);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 200ms;
}
.mode-selector button:hover {
border-color: var(--accent-2);
color: var(--text);
}
.mode-selector button.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.controls {
margin: 1.5rem 0;
display: flex;

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import Header from './components/Header';
import ConsultorCard from './components/ConsultorCard';
import RankingPaginado from './components/RankingPaginado';
import { rankingService } from './services/api';
import './App.css';
@@ -10,6 +11,7 @@ function App() {
const [error, setError] = useState(null);
const [total, setTotal] = useState(0);
const [limite, setLimite] = useState(10);
const [modo, setModo] = useState('completo');
useEffect(() => {
loadRanking();
@@ -54,24 +56,45 @@ function App() {
<div className="container">
<Header total={total} />
<div className="controls">
<label>
Limite de consultores:
<select value={limite} onChange={(e) => setLimite(Number(e.target.value))}>
<option value={10}>Top 10</option>
<option value={50}>Top 50</option>
<option value={100}>Top 100</option>
<option value={200}>Top 200</option>
<option value={500}>Top 500</option>
</select>
</label>
<div className="mode-selector">
<button
className={modo === 'top' ? 'active' : ''}
onClick={() => setModo('top')}
>
Top N (Rápido)
</button>
<button
className={modo === 'completo' ? 'active' : ''}
onClick={() => setModo('completo')}
>
Ranking Completo (300k)
</button>
</div>
<div className="ranking-list">
{consultores.map((consultor) => (
<ConsultorCard key={consultor.id_pessoa} consultor={consultor} />
))}
</div>
{modo === 'top' ? (
<>
<div className="controls">
<label>
Limite de consultores:
<select value={limite} onChange={(e) => setLimite(Number(e.target.value))}>
<option value={10}>Top 10</option>
<option value={50}>Top 50</option>
<option value={100}>Top 100</option>
<option value={200}>Top 200</option>
<option value={500}>Top 500</option>
</select>
</label>
</div>
<div className="ranking-list">
{consultores.map((consultor) => (
<ConsultorCard key={consultor.id_pessoa} consultor={consultor} />
))}
</div>
</>
) : (
<RankingPaginado />
)}
<footer>
<p>Dados: ATUACAPES (Elasticsearch) + SUCUPIRA_PAINEL (Oracle)</p>

View File

@@ -0,0 +1,197 @@
.ranking-paginado {
padding: 1rem;
max-width: 1400px;
margin: 0 auto;
}
.job-progress {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.job-progress h3 {
margin: 0 0 1rem 0;
color: #856404;
}
.progress-bar {
width: 100%;
height: 30px;
background: #e0e0e0;
border-radius: 15px;
overflow: hidden;
margin: 1rem 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4caf50, #66bb6a);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
transition: width 0.3s ease;
}
.estatisticas {
background: #f5f5f5;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.estatisticas h3 {
margin: 0 0 1rem 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat-label {
font-size: 0.85rem;
color: #666;
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
color: #333;
}
.btn-processar {
padding: 0.75rem 1.5rem;
background: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background 0.2s;
}
.btn-processar:hover:not(:disabled) {
background: #1976d2;
}
.btn-processar:disabled {
background: #ccc;
cursor: not-allowed;
}
.loading,
.error {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
}
.error {
color: #d32f2f;
}
.ranking-table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
.ranking-table thead {
background: #1976d2;
color: white;
}
.ranking-table th,
.ranking-table td {
padding: 0.75rem 1rem;
text-align: left;
}
.ranking-table th {
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
}
.ranking-table tbody tr:nth-child(even) {
background: #f9f9f9;
}
.ranking-table tbody tr:hover {
background: #e3f2fd;
}
.posicao {
font-weight: 700;
color: #1976d2;
}
.nome {
font-weight: 500;
}
.pontuacao-total {
font-weight: 700;
color: #2e7d32;
font-size: 1.1rem;
}
.ativo {
color: #2e7d32;
font-weight: 600;
}
.inativo {
color: #666;
}
.paginacao {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
padding: 1rem;
}
.paginacao button {
padding: 0.5rem 1rem;
background: #1976d2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.paginacao button:hover:not(:disabled) {
background: #1565c0;
}
.paginacao button:disabled {
background: #ccc;
cursor: not-allowed;
}
.page-info {
font-weight: 500;
color: #333;
}

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import './RankingPaginado.css';
const RankingPaginado = () => {
const [consultores, setConsultores] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [size] = useState(50);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [jobStatus, setJobStatus] = useState(null);
const [estatisticas, setEstatisticas] = useState(null);
const fetchRanking = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/v1/ranking/paginado?page=${page}&size=${size}`);
if (!response.ok) throw new Error('Erro ao buscar ranking');
const data = await response.json();
setConsultores(data.consultores);
setTotal(data.total);
setTotalPages(data.total_pages);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const fetchJobStatus = async () => {
try {
const response = await fetch('/api/v1/ranking/status');
if (!response.ok) return;
const data = await response.json();
setJobStatus(data);
} catch (err) {
console.error('Erro ao buscar status do job:', err);
}
};
const fetchEstatisticas = async () => {
try {
const response = await fetch('/api/v1/ranking/estatisticas');
if (!response.ok) return;
const data = await response.json();
setEstatisticas(data);
} catch (err) {
console.error('Erro ao buscar estatísticas:', err);
}
};
const processarRanking = async () => {
try {
const response = await fetch('/api/v1/ranking/processar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limpar_antes: true })
});
if (!response.ok) throw new Error('Erro ao processar ranking');
alert('Processamento iniciado! Acompanhe o progresso abaixo.');
} catch (err) {
alert('Erro: ' + err.message);
}
};
useEffect(() => {
fetchRanking();
fetchEstatisticas();
}, [page]);
useEffect(() => {
fetchJobStatus();
const interval = setInterval(fetchJobStatus, 5000);
return () => clearInterval(interval);
}, []);
const irParaPagina = (novaPagina) => {
if (novaPagina >= 1 && novaPagina <= totalPages) {
setPage(novaPagina);
}
};
return (
<div className="ranking-paginado">
{jobStatus && jobStatus.running && (
<div className="job-progress">
<h3>Processamento em andamento...</h3>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${jobStatus.progress}%` }}>
{jobStatus.progress}%
</div>
</div>
<p>{jobStatus.mensagem}</p>
<p>
{jobStatus.processados.toLocaleString('pt-BR')} / {jobStatus.total.toLocaleString('pt-BR')} consultores
{jobStatus.tempo_decorrido && ` | Tempo: ${jobStatus.tempo_decorrido}`}
{jobStatus.tempo_estimado && ` | Estimado: ${jobStatus.tempo_estimado}`}
</p>
</div>
)}
{estatisticas && (
<div className="estatisticas">
<h3>Estatísticas do Ranking</h3>
<div className="stats-grid">
<div className="stat">
<span className="stat-label">Total:</span>
<span className="stat-value">{estatisticas.total_consultores.toLocaleString('pt-BR')}</span>
</div>
<div className="stat">
<span className="stat-label">Ativos:</span>
<span className="stat-value">{estatisticas.total_ativos.toLocaleString('pt-BR')}</span>
</div>
<div className="stat">
<span className="stat-label">Pontuação Média:</span>
<span className="stat-value">{estatisticas.pontuacao_media.toFixed(1)}</span>
</div>
<div className="stat">
<span className="stat-label">Atualizado:</span>
<span className="stat-value">
{estatisticas.ultima_atualizacao
? new Date(estatisticas.ultima_atualizacao).toLocaleString('pt-BR')
: 'Nunca'}
</span>
</div>
</div>
<button onClick={processarRanking} disabled={jobStatus?.running} className="btn-processar">
{jobStatus?.running ? 'Processando...' : 'Reprocessar Ranking'}
</button>
</div>
)}
{loading && <div className="loading">Carregando...</div>}
{error && <div className="error">Erro: {error}</div>}
{!loading && !error && consultores.length > 0 && (
<>
<table className="ranking-table">
<thead>
<tr>
<th>Posição</th>
<th>Nome</th>
<th>Pontuação Total</th>
<th>Comp. A</th>
<th>Comp. B</th>
<th>Comp. C</th>
<th>Comp. D</th>
<th>Status</th>
<th>Anos</th>
</tr>
</thead>
<tbody>
{consultores.map((consultor) => (
<tr key={consultor.id_pessoa}>
<td className="posicao">#{consultor.posicao}</td>
<td className="nome">{consultor.nome}</td>
<td className="pontuacao-total">{consultor.pontuacao_total.toFixed(1)}</td>
<td>{consultor.componente_a.toFixed(1)}</td>
<td>{consultor.componente_b.toFixed(1)}</td>
<td>{consultor.componente_c.toFixed(1)}</td>
<td>{consultor.componente_d.toFixed(1)}</td>
<td className={consultor.ativo ? 'ativo' : 'inativo'}>
{consultor.ativo ? 'Ativo' : 'Inativo'}
</td>
<td>{consultor.anos_atuacao.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
<div className="paginacao">
<button onClick={() => irParaPagina(1)} disabled={page === 1}>
Primeira
</button>
<button onClick={() => irParaPagina(page - 1)} disabled={page === 1}>
Anterior
</button>
<span className="page-info">
Página {page} de {totalPages} | Total: {total.toLocaleString('pt-BR')} consultores
</span>
<button onClick={() => irParaPagina(page + 1)} disabled={page === totalPages}>
Próxima
</button>
<button onClick={() => irParaPagina(totalPages)} disabled={page === totalPages}>
Última
</button>
</div>
</>
)}
</div>
);
};
export default RankingPaginado;