feat(pdf): adicionar exportação de ficha do consultor em PDF

- Novo endpoint GET /api/v1/consultor/{id}/pdf para download
- Serviço PDFService usando WeasyPrint para geração
- Template HTML com layout padrão governo federal
- Botão de exportar PDF no card e modal de dados brutos
This commit is contained in:
Frederico Castro
2025-12-18 22:43:42 -03:00
parent 9d93e42a12
commit 47f0a80f3f
13 changed files with 1607 additions and 9 deletions

View File

@@ -0,0 +1,145 @@
from pathlib import Path
from datetime import datetime
from typing import Any, Dict
from dataclasses import is_dataclass, asdict
from jinja2 import Environment, FileSystemLoader
class PDFService:
def __init__(self):
self.template_dir = Path(__file__).parent.parent.parent / "infrastructure" / "pdf" / "templates"
self.env = Environment(
loader=FileSystemLoader(str(self.template_dir)),
autoescape=True
)
self.env.filters['format_date'] = self._format_date
self.env.filters['format_date_short'] = self._format_date_short
self.template = self.env.get_template("ficha_consultor.html")
def _format_date(self, date_str: str) -> str:
if not date_str:
return "-"
try:
if "/" in date_str:
return date_str
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
return dt.strftime("%d/%m/%Y")
except (ValueError, AttributeError):
return str(date_str)
def _format_date_short(self, date_str: str) -> str:
if not date_str:
return "-"
try:
meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun',
'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez']
if "/" in date_str:
parts = date_str.split("/")
if len(parts) >= 2:
mes = int(parts[1]) if len(parts) == 3 else int(parts[0])
ano = parts[2] if len(parts) == 3 else parts[1]
return f"{meses[mes-1]}/{ano}"
return date_str
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
return f"{meses[dt.month-1]}/{dt.year}"
except (ValueError, AttributeError, IndexError):
return str(date_str)[:10] if date_str else "-"
def _consultor_to_dict(self, consultor: Any) -> Dict:
if is_dataclass(consultor) and not isinstance(consultor, type):
return asdict(consultor)
elif hasattr(consultor, 'model_dump'):
return consultor.model_dump()
elif hasattr(consultor, 'dict'):
return consultor.dict()
elif isinstance(consultor, dict):
return consultor
else:
return vars(consultor)
def gerar_ficha_consultor(self, consultor: Any, raw_document: Any = None) -> bytes:
from weasyprint import HTML, CSS
consultor_data = self._consultor_to_dict(consultor)
raw_data = self._consultor_to_dict(raw_document) if raw_document else {}
html_content = self.template.render(
consultor=ConsultorWrapper(consultor_data),
raw=DictWrapper(raw_data) if raw_data else DictWrapper({}),
data_geracao=datetime.now().strftime("%d/%m/%Y às %H:%M"),
pontuacao_coord=self._extrair_pontuacao_coord(consultor_data),
)
css_path = self.template_dir / "styles.css"
pdf_bytes = HTML(
string=html_content,
base_url=str(self.template_dir)
).write_pdf(
stylesheets=[CSS(filename=str(css_path))]
)
return pdf_bytes
def _extrair_pontuacao_coord(self, consultor_data: Dict) -> Dict:
result = {}
pontuacao = consultor_data.get('pontuacao')
if not pontuacao:
return result
bloco_a = pontuacao.get('bloco_a', {})
atuacoes = bloco_a.get('atuacoes', [])
for atuacao in atuacoes:
codigo = atuacao.get('codigo', '')
result[codigo] = {
'base': atuacao.get('base', 0),
'tempo': atuacao.get('tempo', 0),
'bonus': atuacao.get('bonus', 0),
'total': atuacao.get('total', 0),
}
return result
class ConsultorWrapper:
def __init__(self, data: Dict):
self._data = data
def __getattr__(self, name: str):
value = self._data.get(name)
if value is None:
return None
if isinstance(value, dict):
return DictWrapper(value)
if isinstance(value, list):
return [DictWrapper(item) if isinstance(item, dict) else item for item in value]
return value
def __bool__(self):
return bool(self._data)
class DictWrapper:
def __init__(self, data: Dict):
self._data = data
def __getattr__(self, name: str):
value = self._data.get(name)
if value is None:
return None
if isinstance(value, dict):
return DictWrapper(value)
if isinstance(value, list):
return [DictWrapper(item) if isinstance(item, dict) else item for item in value]
return value
def __bool__(self):
return bool(self._data)
def __str__(self):
return str(self._data)
def get(self, key: str, default=None):
return self._data.get(key, default)

View File

@@ -0,0 +1,941 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Cadastro do Consultor - {{ consultor.nome }}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
{% set dp = raw.dadosPessoais if raw.dadosPessoais else {} %}
{% set emails = raw.emails if raw.emails else [] %}
{% set telefones = raw.telefones if raw.telefones else [] %}
{% set enderecos = raw.enderecos if raw.enderecos else [] %}
{% set identificadores = raw.identificadoresRegistrados if raw.identificadoresRegistrados else [] %}
{% set identificador_lattes = raw.identificadorLattes if raw.identificadorLattes else None %}
{% set titulacoes = raw.titulacoes if raw.titulacoes else [] %}
{% set idiomas = raw.idiomas if raw.idiomas else [] %}
{% set areas = raw.areasConhecimento if raw.areasConhecimento else [] %}
{% set papeis = raw.papeis if raw.papeis else [] %}
{% set bolsas = raw.bolsas if raw.bolsas else [] %}
{% set dados_bancarios = raw.dadosBancarios if raw.dadosBancarios else [] %}
{% set documentos = raw.documento if raw.documento else [] %}
{% set atuacoes_raw = raw.atuacoes if raw.atuacoes else [] %}
{% set total_coordenacoes = consultor.coordenacoes_capes|length if consultor.coordenacoes_capes else 0 %}
{% set total_inscricoes = consultor.inscricoes|length if consultor.inscricoes else 0 %}
{% set total_avaliacoes = consultor.avaliacoes_comissao|length if consultor.avaliacoes_comissao else 0 %}
{% set total_premiacoes = consultor.premiacoes|length if consultor.premiacoes else 0 %}
{% set total_bolsas_cnpq = consultor.bolsas_cnpq|length if consultor.bolsas_cnpq else 0 %}
{% set total_participacoes = consultor.participacoes|length if consultor.participacoes else 0 %}
{% set total_orientacoes = consultor.orientacoes|length if consultor.orientacoes else 0 %}
{% set total_bancas = consultor.membros_banca|length if consultor.membros_banca else 0 %}
<div class="documento">
<section class="cover">
<div>
<table class="cover-header">
<tr>
<td>
<strong>MINISTÉRIO DA EDUCAÇÃO</strong><br>
Coordenação de Aperfeiçoamento de Pessoal de Nível Superior
</td>
<td class="right small">Relatório Executivo</td>
</tr>
</table>
<div class="cover-title">Cadastro do Consultor</div>
<div class="cover-subtitle">Sistema de Ranking AtuaCAPES · Documento de Alta Gestão</div>
<div class="cover-name">{{ consultor.nome }}</div>
<div class="tag-list">
<span class="badge {{ 'success' if consultor.ativo else 'danger' }}">{{ 'Ativo' if consultor.ativo else 'Histórico' }}</span>
{% if consultor.veterano %}
<span class="badge">Veterano</span>
{% endif %}
{% if consultor.coordenador_ppg %}
<span class="badge">Coordenação PPG</span>
{% endif %}
</div>
</div>
<table class="cover-meta">
<tr>
<td><strong>ID Pessoa:</strong> {{ consultor.id_pessoa }}</td>
<td><strong>Posição no Ranking:</strong> {{ consultor.rank or '-' }}º</td>
</tr>
<tr>
<td><strong>Pontuação Total:</strong> {{ consultor.pontuacao.pontuacao_total }}</td>
<td><strong>Anos de Atuação:</strong> {{ "%.1f"|format(consultor.anos_atuacao) }} anos</td>
</tr>
<tr>
<td><strong>Data de Geração:</strong> {{ data_geracao }}</td>
<td><strong>Origem:</strong> Elasticsearch / ATUACAPES</td>
</tr>
</table>
<div class="section-divider"></div>
<div class="section-title">Resumo Executivo</div>
<p class="lead">Visão consolidada dos indicadores do consultor, com foco em desempenho, volume e posicionamento estratégico.</p>
<table class="kpi-table">
<tr>
<td>
<div class="kpi-label">Pontuação total</div>
<div class="kpi-value">{{ consultor.pontuacao.pontuacao_total }}</div>
<div class="kpi-sub">Blocos A+C+D consolidados</div>
</td>
<td>
<div class="kpi-label">Ranking nacional</div>
<div class="kpi-value">{{ consultor.rank or '-' }}º</div>
<div class="kpi-sub">Posição atual no ranking</div>
</td>
<td>
<div class="kpi-label">Anos de atuação</div>
<div class="kpi-value">{{ "%.1f"|format(consultor.anos_atuacao) }}</div>
<div class="kpi-sub">Tempo de atuação CAPES</div>
</td>
<td>
<div class="kpi-label">Status</div>
<div class="kpi-value">{{ 'Ativo' if consultor.ativo else 'Histórico' }}</div>
<div class="kpi-sub">{{ 'Consultoria em vigor' if consultor.ativo else 'Sem consultoria ativa' }}</div>
</td>
</tr>
</table>
<div class="subsection-title">Indicadores de Volume</div>
<table class="kpi-table">
<tr>
<td>
<div class="kpi-label">Coordenações CAPES</div>
<div class="kpi-value">{{ total_coordenacoes }}</div>
<div class="kpi-sub">Bloco A</div>
</td>
<td>
<div class="kpi-label">Consultorias & Vínculos</div>
<div class="kpi-value">{{ consultor.consultoria.vinculos|length if consultor.consultoria and consultor.consultoria.vinculos else 0 }}</div>
<div class="kpi-sub">Bloco C</div>
</td>
<td>
<div class="kpi-label">Premiações & Avaliações</div>
<div class="kpi-value">{{ total_premiacoes + total_avaliacoes + total_inscricoes }}</div>
<div class="kpi-sub">Bloco D</div>
</td>
<td>
<div class="kpi-label">Atuações no ES</div>
<div class="kpi-value">{{ atuacoes_raw|length }}</div>
<div class="kpi-sub">Base completa</div>
</td>
</tr>
</table>
</section>
<section class="section force-page-break">
<div class="section-title">Identificação e Perfil</div>
<table class="meta-table">
<tr>
<td class="label">Nome</td>
<td class="value">{{ dp.nome or consultor.nome }}</td>
</tr>
<tr>
<td class="label">Nascimento</td>
<td class="value">{{ dp.nascimento or '-' }}</td>
</tr>
<tr>
<td class="label">Gênero</td>
<td class="value">{{ dp.genero or '-' }}</td>
</tr>
<tr>
<td class="label">Raça/Cor</td>
<td class="value">{{ dp.raca or '-' }}</td>
</tr>
<tr>
<td class="label">Nacionalidade</td>
<td class="value">{{ dp.nacionalidade or '-' }}</td>
</tr>
<tr>
<td class="label">Município/UF</td>
<td class="value">{{ dp.municipio or '-' }}{% if dp.uf %} / {{ dp.uf }}{% endif %}</td>
</tr>
<tr>
<td class="label">Estado civil</td>
<td class="value">{{ dp.estadoCivil or '-' }}</td>
</tr>
<tr>
<td class="label">Nome da mãe</td>
<td class="value">{{ dp.nomeMae or '-' }}</td>
</tr>
<tr>
<td class="label">Nome do pai</td>
<td class="value">{{ dp.nomePai or '-' }}</td>
</tr>
<tr>
<td class="label">Tipo de pessoa</td>
<td class="value">{{ dp.tipo or '-' }}</td>
</tr>
<tr>
<td class="label">Ano de óbito</td>
<td class="value">{{ dp.anoObito or '-' }}</td>
</tr>
</table>
</section>
<section class="section force-page-break">
<div class="section-title">Contato e Identificadores</div>
<div class="subsection-title">E-mails</div>
{% if emails %}
<table class="data-table">
<thead>
<tr>
<th>E-mail</th>
<th>Tipo</th>
<th>Principal</th>
<th>Última alteração</th>
</tr>
</thead>
<tbody>
{% for e in emails %}
<tr>
<td>{{ e.email }}</td>
<td>{{ e.tipo or '-' }}</td>
<td>{{ e.principalFinalidade or '-' }}</td>
<td>{{ e.ultimaAlteracao or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de e-mail.</p>
{% endif %}
<div class="subsection-title">Telefones</div>
{% if telefones %}
<table class="data-table">
<thead>
<tr>
<th>Telefone</th>
<th>Tipo</th>
<th>Finalidade</th>
<th>Principal</th>
</tr>
</thead>
<tbody>
{% for t in telefones %}
<tr>
<td>({{ t.codigo }}) {{ t.numero }}</td>
<td>{{ t.tipo or '-' }}</td>
<td>{{ t.finalidade or '-' }}</td>
<td>{{ t.principalFinalidade or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de telefone.</p>
{% endif %}
<div class="subsection-title">Endereços</div>
{% if enderecos %}
<table class="data-table">
<thead>
<tr>
<th>Tipo</th>
<th>Endereço</th>
<th>Cidade</th>
<th>CEP</th>
<th>Principal</th>
</tr>
</thead>
<tbody>
{% for e in enderecos %}
<tr>
<td>{{ e.tipo or '-' }}</td>
<td>{{ e.endereco or '-' }}{% if e.numero %}, {{ e.numero }}{% endif %}{% if e.complemento %} - {{ e.complemento }}{% endif %}</td>
<td>{{ e.cidadeExterior or '-' }}</td>
<td>{{ e.cep or '-' }}</td>
<td>{{ e.principalFinalidade or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de endereço.</p>
{% endif %}
<div class="subsection-title">Identificadores e Documentos</div>
{% if identificadores or documentos or identificador_lattes %}
<table class="data-table">
<thead>
<tr>
<th>Tipo</th>
<th>Descrição</th>
<th>Órgão expedidor</th>
<th>Validade</th>
</tr>
</thead>
<tbody>
{% for i in identificadores %}
<tr>
<td>{{ i.tipo or '-' }}</td>
<td>{{ i.descricao or '-' }}</td>
<td>{{ i.orgaoExpedidor or '-' }}</td>
<td>{{ i.inicioValidade or '-' }}{% if i.fimValidade %} a {{ i.fimValidade }}{% endif %}</td>
</tr>
{% endfor %}
{% if identificador_lattes %}
<tr>
<td>{{ identificador_lattes.tipo or 'Identificador Lattes' }}</td>
<td>{{ identificador_lattes.descricao or '-' }}</td>
<td>{{ identificador_lattes.orgaoExpedidor or '-' }}</td>
<td>{{ identificador_lattes.inicioValidade or '-' }}{% if identificador_lattes.fimValidade %} a {{ identificador_lattes.fimValidade }}{% endif %}</td>
</tr>
{% endif %}
{% for d in documentos %}
<tr>
<td>{{ d.tipo or '-' }}</td>
<td>{{ d.descricao or '-' }}</td>
<td>{{ d.orgaoExpedidor or '-' }}</td>
<td>{{ d.inicioValidade or '-' }}{% if d.fimValidade %} a {{ d.fimValidade }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de identificadores/documentos.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Formação, Áreas e Competências</div>
<div class="subsection-title">Titulações</div>
{% if titulacoes %}
<table class="data-table">
<thead>
<tr>
<th>Grau</th>
<th>IES</th>
<th>Área</th>
<th>Programa</th>
<th>Período</th>
</tr>
</thead>
<tbody>
{% for t in titulacoes %}
<tr>
<td>{{ t.grauAcademico.nome if t.grauAcademico else '-' }}</td>
<td>{{ t.ies.nome if t.ies else '-' }}{% if t.ies and t.ies.sigla %} ({{ t.ies.sigla }}){% endif %}</td>
<td>{{ t.areaConhecimento.nome if t.areaConhecimento else '-' }}</td>
<td>{{ t.programa.nome if t.programa else '-' }}</td>
<td>{{ t.inicio|format_date_short if t.inicio else '-' }}{% if t.fim %} a {{ t.fim|format_date_short }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de titulação.</p>
{% endif %}
<div class="subsection-title">Áreas de conhecimento</div>
{% if areas %}
<div class="tag-list">
{% for a in areas %}
<span class="tag">{{ a.nome or '-' }}</span>
{% endfor %}
</div>
{% else %}
<p class="note">Sem áreas de conhecimento registradas.</p>
{% endif %}
<div class="subsection-title">Idiomas</div>
{% if idiomas %}
<div class="tag-list">
{% for i in idiomas %}
<span class="tag">{{ i.idioma or i.nome or '-' }}{% if i.nivel %} · {{ i.nivel }}{% endif %}</span>
{% endfor %}
</div>
{% else %}
<p class="note">Sem idiomas registrados.</p>
{% endif %}
<div class="subsection-title">Bolsas (base ES)</div>
{% if bolsas %}
<table class="data-table">
<thead>
<tr>
<th>Programa</th>
<th>Instituição</th>
<th>Período</th>
</tr>
</thead>
<tbody>
{% for b in bolsas %}
<tr>
<td>{{ b.programa or b.tipo or '-' }}</td>
<td>{{ b.ies.nome if b.ies else '-' }}</td>
<td>{{ b.inicio|format_date_short if b.inicio else '-' }}{% if b.fim %} a {{ b.fim|format_date_short }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem bolsas registradas na base ES.</p>
{% endif %}
<div class="subsection-title">Bolsas CNPq (pontuação)</div>
{% if consultor.bolsas_cnpq %}
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Nível</th>
<th>Área</th>
</tr>
</thead>
<tbody>
{% for b in consultor.bolsas_cnpq %}
<tr>
<td>{{ b.codigo }}</td>
<td>{{ b.nivel }}</td>
<td>{{ b.area or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem bolsas CNPq associadas.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Governança, Papéis e Consultoria</div>
<div class="subsection-title">Papéis cadastrados</div>
{% if papeis %}
<table class="data-table">
<thead>
<tr>
<th>Tipo</th>
<th>Status</th>
<th>Origem</th>
</tr>
</thead>
<tbody>
{% for p in papeis %}
<tr>
<td>{{ p.tipo or '-' }}</td>
<td>{{ p.status or '-' }}</td>
<td>{{ p.procedencia.origem if p.procedencia else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem papéis registrados na base ES.</p>
{% endif %}
<div class="subsection-title">Consultoria CAPES</div>
{% if consultor.consultoria %}
<table class="meta-table">
<tr>
<td class="label">Código</td>
<td class="value">{{ consultor.consultoria.codigo }}</td>
</tr>
<tr>
<td class="label">Situação</td>
<td class="value">{{ consultor.consultoria.situacao }}</td>
</tr>
<tr>
<td class="label">Período</td>
<td class="value">{{ consultor.consultoria.periodo.inicio|format_date if consultor.consultoria.periodo else '-' }} a {{ consultor.consultoria.periodo.fim|format_date if consultor.consultoria.periodo and consultor.consultoria.periodo.fim else 'Atual' }}</td>
</tr>
<tr>
<td class="label">Áreas</td>
<td class="value">{{ consultor.consultoria.areas|join(', ') if consultor.consultoria.areas else '-' }}</td>
</tr>
<tr>
<td class="label">Anos consecutivos</td>
<td class="value">{{ consultor.consultoria.anos_consecutivos }}</td>
</tr>
<tr>
<td class="label">Retornos</td>
<td class="value">{{ consultor.consultoria.retornos }}</td>
</tr>
</table>
{% if consultor.consultoria.vinculos %}
<div class="subsection-title">Vínculos institucionais</div>
<table class="data-table">
<thead>
<tr>
<th>IES</th>
<th>Período</th>
<th>Situação</th>
</tr>
</thead>
<tbody>
{% for v in consultor.consultoria.vinculos %}
<tr>
<td>{{ v.ies.nome if v.ies else '-' }}{% if v.ies and v.ies.sigla %} ({{ v.ies.sigla }}){% endif %}</td>
<td>{{ v.periodo.inicio|format_date_short if v.periodo else '-' }} a {{ v.periodo.fim|format_date_short if v.periodo and v.periodo.fim else 'Atual' }}</td>
<td>{{ v.situacao or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<p class="note">Sem registros de consultoria ativa ou histórica.</p>
{% endif %}
<div class="subsection-title">Coordenação de PPG</div>
<p class="lead">{{ 'Possui atuação de coordenação de PPG.' if consultor.coordenador_ppg else 'Sem indicação de coordenação de PPG nos registros.' }}</p>
</section>
<section class="section force-page-break">
<div class="section-title">Pontuação Consolidada</div>
<p class="lead">Resumo dos blocos de pontuação conforme metodologia do Ranking AtuaCAPES.</p>
<table class="data-table">
<thead>
<tr>
<th>Bloco</th>
<th>Descrição</th>
<th>Pontos</th>
</tr>
</thead>
<tbody>
<tr>
<td>A</td>
<td>Coordenação CAPES</td>
<td>{{ consultor.pontuacao.bloco_a.total }}</td>
</tr>
<tr>
<td>B</td>
<td>Coordenação PPG (reservado)</td>
<td>0</td>
</tr>
<tr>
<td>C</td>
<td>Consultoria CAPES</td>
<td>{{ consultor.pontuacao.bloco_c.total }}</td>
</tr>
<tr>
<td>D</td>
<td>Premiações, Avaliações e Participações</td>
<td>{{ consultor.pontuacao.bloco_d.total }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2" class="right"><strong>Pontuação total</strong></td>
<td><strong>{{ consultor.pontuacao.pontuacao_total }}</strong></td>
</tr>
</tfoot>
</table>
</section>
<section class="section force-page-break">
<div class="section-title">Bloco A — Coordenações CAPES</div>
{% if consultor.coordenacoes_capes %}
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Área de avaliação</th>
<th>Período</th>
<th>Anos</th>
<th>Presidente</th>
</tr>
</thead>
<tbody>
{% for coord in consultor.coordenacoes_capes %}
<tr>
<td>{{ coord.codigo }}</td>
<td>{{ coord.area_avaliacao or '-' }}</td>
<td>{{ coord.periodo.inicio|format_date_short if coord.periodo else '-' }} a {{ coord.periodo.fim|format_date_short if coord.periodo and coord.periodo.fim else 'Atual' }}</td>
<td>{{ "%.1f"|format(coord.periodo.anos_decorridos) if coord.periodo else '-' }}</td>
<td>{{ 'Sim' if coord.presidente else 'Não' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if consultor.pontuacao.bloco_a.atuacoes %}
<div class="subsection-title">Pontuação detalhada</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Quantidade</th>
<th>Base</th>
<th>Tempo</th>
<th>Bônus</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for at in consultor.pontuacao.bloco_a.atuacoes %}
<tr>
<td>{{ at.codigo }}</td>
<td>{{ at.quantidade }}</td>
<td>{{ at.base }}</td>
<td>{{ at.tempo }}</td>
<td>{{ at.bonus }}</td>
<td><strong>{{ at.total }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<p class="note">Nenhuma coordenação CAPES registrada para este consultor.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Bloco C — Consultoria CAPES</div>
{% if consultor.consultoria %}
<table class="meta-table">
<tr>
<td class="label">Código</td>
<td class="value">{{ consultor.consultoria.codigo }}</td>
</tr>
<tr>
<td class="label">Situação</td>
<td class="value">{{ consultor.consultoria.situacao }}</td>
</tr>
<tr>
<td class="label">Período</td>
<td class="value">{{ consultor.consultoria.periodo.inicio|format_date if consultor.consultoria.periodo else '-' }} a {{ consultor.consultoria.periodo.fim|format_date if consultor.consultoria.periodo and consultor.consultoria.periodo.fim else 'Atual' }}</td>
</tr>
<tr>
<td class="label">Anos consecutivos</td>
<td class="value">{{ consultor.consultoria.anos_consecutivos }}</td>
</tr>
<tr>
<td class="label">Retornos</td>
<td class="value">{{ consultor.consultoria.retornos }}</td>
</tr>
</table>
{% if consultor.consultoria.vinculos %}
<div class="subsection-title">Vínculos institucionais</div>
<table class="data-table">
<thead>
<tr>
<th>IES</th>
<th>Período</th>
<th>Situação</th>
</tr>
</thead>
<tbody>
{% for v in consultor.consultoria.vinculos %}
<tr>
<td>{{ v.ies.nome if v.ies else '-' }}{% if v.ies and v.ies.sigla %} ({{ v.ies.sigla }}){% endif %}</td>
<td>{{ v.periodo.inicio|format_date_short if v.periodo else '-' }} a {{ v.periodo.fim|format_date_short if v.periodo and v.periodo.fim else 'Atual' }}</td>
<td>{{ v.situacao or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.pontuacao.bloco_c.atuacoes %}
<div class="subsection-title">Pontuação detalhada</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Quantidade</th>
<th>Base</th>
<th>Tempo</th>
<th>Bônus</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for at in consultor.pontuacao.bloco_c.atuacoes %}
<tr>
<td>{{ at.codigo }}</td>
<td>{{ at.quantidade }}</td>
<td>{{ at.base }}</td>
<td>{{ at.tempo }}</td>
<td>{{ at.bonus }}</td>
<td><strong>{{ at.total }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<p class="note">Nenhum registro de consultoria encontrado para este consultor.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Bloco D — Premiações, Avaliações e Participações</div>
{% if consultor.avaliacoes_comissao %}
<div class="subsection-title">Avaliações em comissões ({{ consultor.avaliacoes_comissao|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Prêmio</th>
<th>Ano</th>
<th>Comissão</th>
</tr>
</thead>
<tbody>
{% for a in consultor.avaliacoes_comissao %}
<tr>
<td>{{ a.codigo }}</td>
<td>{{ a.tipo }}</td>
<td>{{ a.premio }}</td>
<td>{{ a.ano }}</td>
<td>{{ a.nome_comissao or '-' }}{% if a.comissao_tipo %} ({{ a.comissao_tipo }}){% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.premiacoes %}
<div class="subsection-title">Premiações recebidas ({{ consultor.premiacoes|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Prêmio</th>
<th>Ano</th>
<th>Papel</th>
</tr>
</thead>
<tbody>
{% for p in consultor.premiacoes %}
<tr>
<td>{{ p.codigo }}</td>
<td>{{ p.tipo }}</td>
<td>{{ p.nome_premio }}</td>
<td>{{ p.ano }}</td>
<td>{{ p.papel or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.inscricoes %}
<div class="subsection-title">Inscrições em prêmios ({{ consultor.inscricoes|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Prêmio</th>
<th>Ano</th>
<th>Situação</th>
</tr>
</thead>
<tbody>
{% for i in consultor.inscricoes %}
<tr>
<td>{{ i.codigo }}</td>
<td>{{ i.tipo }}</td>
<td>{{ i.premio }}</td>
<td>{{ i.ano }}</td>
<td>{{ i.situacao }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.orientacoes %}
<div class="subsection-title">Orientações acadêmicas ({{ consultor.orientacoes|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Nível</th>
<th>Ano</th>
<th>Co-orient.</th>
<th>Premiada</th>
</tr>
</thead>
<tbody>
{% for o in consultor.orientacoes %}
<tr>
<td>{{ o.codigo }}</td>
<td>{{ o.tipo }}</td>
<td>{{ o.nivel }}</td>
<td>{{ o.ano or '-' }}</td>
<td>{{ 'Sim' if o.coorientacao else 'Não' }}</td>
<td>{{ 'Sim' if o.premiada else 'Não' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.membros_banca %}
<div class="subsection-title">Bancas examinadoras ({{ consultor.membros_banca|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Nível</th>
<th>Ano</th>
</tr>
</thead>
<tbody>
{% for b in consultor.membros_banca %}
<tr>
<td>{{ b.codigo }}</td>
<td>{{ b.tipo }}</td>
<td>{{ b.nivel }}</td>
<td>{{ b.ano or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.participacoes %}
<div class="subsection-title">Participações ({{ consultor.participacoes|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Descrição</th>
<th>Ano</th>
</tr>
</thead>
<tbody>
{% for p in consultor.participacoes %}
<tr>
<td>{{ p.codigo }}</td>
<td>{{ p.tipo }}</td>
<td>{{ p.descricao }}</td>
<td>{{ p.ano or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.pontuacao.bloco_d.atuacoes %}
<div class="subsection-title">Pontuação detalhada</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Quantidade</th>
<th>Base</th>
<th>Tempo</th>
<th>Bônus</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for at in consultor.pontuacao.bloco_d.atuacoes %}
<tr>
<td>{{ at.codigo }}</td>
<td>{{ at.quantidade }}</td>
<td>{{ at.base }}</td>
<td>{{ at.tempo }}</td>
<td>{{ at.bonus }}</td>
<td><strong>{{ at.total }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Atuações no Elasticsearch</div>
<p class="lead">Base completa de atuações registradas no ATUACAPES. Esta seção consolida todas as entradas encontradas no índice.</p>
{% if atuacoes_raw %}
<table class="data-table">
<thead>
<tr>
<th>Tipo</th>
<th>Descrição</th>
<th>Período</th>
<th>Procedência</th>
</tr>
</thead>
<tbody>
{% for a in atuacoes_raw %}
<tr>
<td>{{ a.tipo or '-' }}</td>
<td>{{ a.descricao or a.nome or '-' }}</td>
<td>{{ a.inicio or '-' }}{% if a.fim %} a {{ a.fim }}{% endif %}</td>
<td>{{ a.procedencia.origem if a.procedencia else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem atuações registradas no índice.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Apêndice Técnico</div>
<div class="subsection-title">Dados bancários</div>
{% if dados_bancarios %}
<table class="data-table">
<thead>
<tr>
<th>Banco</th>
<th>Agência</th>
<th>Conta</th>
<th>Tipo</th>
</tr>
</thead>
<tbody>
{% for b in dados_bancarios %}
<tr>
<td>{{ b.banco or '-' }}</td>
<td>{{ b.agencia or '-' }}</td>
<td>{{ b.conta or '-' }}</td>
<td>{{ b.tipoConta or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem dados bancários registrados.</p>
{% endif %}
<div class="subsection-title">Metadados do documento</div>
<table class="meta-table">
<tr>
<td class="label">Consultor ID</td>
<td class="value">{{ consultor.id_pessoa }}</td>
</tr>
<tr>
<td class="label">Fonte</td>
<td class="value">Elasticsearch ATUACAPES</td>
</tr>
<tr>
<td class="label">Data de geração</td>
<td class="value">{{ data_geracao }}</td>
</tr>
</table>
<div class="footer">
Documento gerado automaticamente. Em caso de divergência, prevalecem os registros oficiais do sistema.
</div>
</section>
</div>
</body>
</html>

View File

@@ -0,0 +1,307 @@
@page {
size: A4;
margin: 1.6cm 1.4cm 1.8cm 1.4cm;
@bottom-left {
content: "Sistema de Ranking AtuaCAPES";
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 8pt;
color: #64748b;
}
@bottom-right {
content: "Página " counter(page) " de " counter(pages);
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 8pt;
color: #64748b;
}
}
:root {
--ink: #0f172a;
--muted: #475569;
--accent: #0f3d68;
--accent-2: #1d4ed8;
--border: #e2e8f0;
--bg-soft: #f8fafc;
--bg-strong: #eef2f7;
--success: #15803d;
--danger: #b91c1c;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 10pt;
line-height: 1.45;
color: var(--ink);
background: #ffffff;
}
.documento {
width: 100%;
}
.force-page-break {
page-break-before: always !important;
break-before: page !important;
}
.no-break {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
h1, h2, h3, h4 {
page-break-after: avoid;
break-after: avoid;
}
.cover {
border: 2px solid var(--accent);
padding: 28px 32px;
min-height: 23cm;
display: flex;
flex-direction: column;
justify-content: space-between;
background: var(--bg-soft);
}
.cover-header {
width: 100%;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
margin-bottom: 10px;
border-collapse: collapse;
}
.cover-header td {
vertical-align: top;
}
.cover-title {
font-size: 20pt;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 1.5px;
margin-top: 22px;
}
.cover-subtitle {
color: var(--muted);
font-size: 11pt;
margin-top: 6px;
}
.cover-name {
font-size: 16pt;
font-weight: 700;
margin: 18px 0 8px;
}
.cover-meta {
width: 100%;
border-top: 1px solid var(--border);
padding-top: 10px;
border-collapse: collapse;
font-size: 9.5pt;
color: var(--muted);
}
.cover-meta td {
padding: 4px 0;
}
.cover-meta strong {
color: var(--ink);
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
background: #e2e8f0;
color: var(--ink);
font-size: 8.5pt;
font-weight: 600;
margin-right: 6px;
}
.badge.success {
background: #dcfce7;
color: var(--success);
}
.badge.danger {
background: #fee2e2;
color: var(--danger);
}
.section {
padding: 10px 0 4px;
}
.section-title {
font-size: 13pt;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
border-bottom: 2px solid var(--accent);
padding-bottom: 6px;
margin-bottom: 12px;
}
.subsection-title {
font-size: 11pt;
font-weight: 600;
color: var(--ink);
margin: 16px 0 8px;
}
.lead {
color: var(--muted);
margin-bottom: 10px;
}
.kpi-table {
width: 100%;
border-collapse: separate;
border-spacing: 8px;
margin-bottom: 4px;
}
.kpi-table td {
background: var(--bg-strong);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
vertical-align: top;
}
.kpi-label {
font-size: 8.5pt;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 6px;
}
.kpi-value {
font-size: 14pt;
font-weight: 700;
color: var(--accent);
}
.kpi-sub {
font-size: 9pt;
color: var(--muted);
}
.data-table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--border);
margin-bottom: 10px;
}
.data-table thead {
background: var(--accent);
color: #ffffff;
}
.data-table th,
.data-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
.data-table th {
font-size: 8.5pt;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.data-table tbody tr:nth-child(even) {
background: #f8fafc;
}
.label {
color: var(--muted);
font-weight: 600;
width: 35%;
}
.value {
color: var(--ink);
}
.meta-table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--border);
}
.meta-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.meta-table tr:nth-child(even) {
background: var(--bg-soft);
}
.tag-list {
margin-top: 6px;
}
.tag {
display: inline-block;
padding: 4px 8px;
margin: 0 6px 6px 0;
background: var(--bg-strong);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 8.5pt;
}
.note {
background: var(--bg-soft);
border: 1px dashed var(--border);
padding: 10px 12px;
color: var(--muted);
font-size: 9pt;
margin-top: 10px;
}
.section-divider {
height: 1px;
background: var(--border);
margin: 16px 0;
}
.footer {
margin-top: 18px;
font-size: 8.5pt;
color: var(--muted);
}
.small {
font-size: 8.5pt;
color: var(--muted);
}
.right {
text-align: right;
}
.center {
text-align: center;
}

View File

@@ -141,19 +141,20 @@ class ConsultorRepositoryImpl(ConsultorRepository):
if not consultorias:
return None
visto_ids: Dict[int, Dict[str, Any]] = {}
vistos: Dict[tuple, Dict[str, Any]] = {}
for c in consultorias:
cid = c.get("id")
if cid is None:
continue
if cid not in visto_ids:
visto_ids[cid] = c
dc = c.get("dadosConsultoria", {}) or {}
ies_id = (dc.get("ies", {}) or {}).get("id")
inicio = c.get("inicio") or dc.get("inicioVinculacao") or dc.get("inicioSituacao")
chave = (ies_id, inicio)
if chave not in vistos:
vistos[chave] = c
else:
existente_fim = visto_ids[cid].get("fim")
existente_fim = vistos[chave].get("fim")
novo_fim = c.get("fim")
if existente_fim and not novo_fim:
visto_ids[cid] = c
consultorias = list(visto_ids.values()) if visto_ids else consultorias
vistos[chave] = c
consultorias = list(vistos.values()) if vistos else consultorias
periodos: List[Periodo] = []
vinculos: List[VinculoConsultoria] = []

View File

@@ -1,6 +1,9 @@
import asyncio
from io import BytesIO
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from typing import Optional, List
from ...application.use_cases.obter_ranking import ObterRankingUseCase
@@ -365,3 +368,45 @@ async def obter_consultor_raw(
return documento
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/consultor/{id_pessoa}/pdf")
async def exportar_ficha_pdf(
id_pessoa: int,
repository: ConsultorRepositoryImpl = Depends(get_repository),
es_client: ElasticsearchClient = Depends(get_es_client),
store = Depends(get_ranking_store),
):
from ...application.services.pdf_service import PDFService
use_case = ObterConsultorUseCase(repository=repository)
rank = None
if store.is_ready():
found = store.get_by_id(id_pessoa)
rank = found.posicao if found else None
consultor = await use_case.executar(id_pessoa=id_pessoa, rank=rank)
if not consultor:
raise HTTPException(status_code=404, detail=f"Consultor {id_pessoa} não encontrado")
try:
pdf_service = PDFService()
raw_documento = await es_client.buscar_documento_completo(id_pessoa)
raw_source = raw_documento.get("_source") if raw_documento else {}
pdf_bytes = pdf_service.gerar_ficha_consultor(consultor, raw_source)
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Erro ao gerar PDF: {str(e)}")
nome_sanitizado = "".join(c if c.isalnum() or c in " -_" else "_" for c in consultor.nome)
nome_arquivo = f"ficha_consultor_{id_pessoa}_{nome_sanitizado[:30]}_{datetime.now().strftime('%Y%m%d')}.pdf"
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{nome_arquivo}"'
}
)