portal_publico/portal_api/dashboard_contabil/resumo_pdf.py

345 lines
15 KiB
Python

"""Geração do PDF de "Resumo" do Relatório Contábil — reúne o Resumo do
Fechamento (texto rico do contador), os cards de indicador e as observações
visíveis ao cliente, o mesmo conteúdo da aba "Resumo" do relatório HTML
"Gerar Dashboard" (ver `_contabil_dados_resumo()` em `views.py`). Identidade
visual do escritório (logo cursivo, roxo/dourado do próprio relatório HTML),
mesmo padrão de `custo_contratacao/pdf.py`/`indicadores/recibo.py` — nunca a
marca "P.I.D." do Portal (ver "Logos" no CLAUDE.md raiz)."""
from __future__ import annotations
import base64
import io
from datetime import date
from pathlib import Path
from typing import Any
from xml.sax.saxutils import escape
import bs4
from PIL import Image as PILImage
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import (
Image,
ListFlowable,
ListItem,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
_LOGO_BRANCO_PATH = Path(__file__).resolve().parents[2] / "static" / "img" / "logo-branco.png"
# Mesma paleta roxo/dourado do próprio relatório HTML (dashboard-contabil-relatorio.html,
# `--roxo-escuro`/`--roxo-mais-escuro`/`--dourado`) — não os tokens de tema do
# Portal (que mudam com a cor escolhida pelo usuário logado).
_COR_ROXO_ESCURO = colors.HexColor("#281552")
_COR_DOURADO = colors.HexColor("#b4872a")
_COR_DOURADO_CLARO_BANNER = colors.HexColor("#cbb98c")
_COR_TEXTO = colors.HexColor("#241c33")
_COR_TEXTO_MUTED = colors.HexColor("#6b6478")
_COR_LINHA = colors.HexColor("#e4e0ec")
_COR_ZEBRA = colors.HexColor("#f7f5fb")
_LARGURA_PAGINA, _ALTURA_PAGINA = A4
_MARGEM = 1.8 * cm
_ALTURA_BANNER = 2.6 * cm
_LARGURA_UTIL = _LARGURA_PAGINA - 2 * _MARGEM
def _dimensoes_imagem(caminho: Path, largura_pt: float) -> tuple[float, float]:
with PILImage.open(caminho) as img:
largura_px, altura_px = img.size
return largura_pt, largura_pt * (altura_px / largura_px)
def _ajusta_titulo_banner(canvas: Canvas, texto: str, largura_maxima: float) -> tuple[str, float]:
"""O nome da empresa é livre (vem do cadastro) e o banner tem largura
fixa — reduz o tamanho da fonte até caber numa linha só e, se mesmo no
tamanho mínimo ainda estourar, corta com reticências. Evita o corte
duro que `drawString` faz por padrão (texto simplesmente sai da página)."""
fonte = "Helvetica-Bold"
tamanho = 15.0
tamanho_minimo = 10.0
while tamanho > tamanho_minimo and canvas.stringWidth(texto, fonte, tamanho) > largura_maxima:
tamanho -= 0.5
if canvas.stringWidth(texto, fonte, tamanho) > largura_maxima:
while texto and canvas.stringWidth(texto + "…", fonte, tamanho) > largura_maxima:
texto = texto[:-1]
texto = f"{texto}…"
return texto, tamanho
def _desenha_banner(canvas: Canvas, nome_empresa: str, competencia_label: str, gerado_em: str) -> None:
topo = _ALTURA_PAGINA
canvas.setFillColor(_COR_ROXO_ESCURO)
canvas.rect(0, topo - _ALTURA_BANNER, _LARGURA_PAGINA, _ALTURA_BANNER, stroke=0, fill=1)
canvas.setFillColor(_COR_DOURADO)
canvas.rect(0, topo - _ALTURA_BANNER - 0.09 * cm, _LARGURA_PAGINA, 0.09 * cm, stroke=0, fill=1)
logo_largura, logo_altura = _dimensoes_imagem(_LOGO_BRANCO_PATH, 2.9 * cm)
logo_y = topo - _ALTURA_BANNER / 2 - logo_altura / 2
canvas.drawImage(str(_LOGO_BRANCO_PATH), _MARGEM, logo_y, width=logo_largura, height=logo_altura, mask="auto")
texto_x = _MARGEM + logo_largura + 0.6 * cm
centro_y = topo - _ALTURA_BANNER / 2
largura_disponivel = _LARGURA_PAGINA - _MARGEM - texto_x
titulo, tamanho_titulo = _ajusta_titulo_banner(
canvas, f"Resumo do Fechamento — {nome_empresa}", largura_disponivel
)
canvas.setFillColor(colors.white)
canvas.setFont("Helvetica-Bold", tamanho_titulo)
canvas.drawString(texto_x, centro_y + 0.2 * cm, titulo)
canvas.setFont("Helvetica", 9.5)
canvas.setFillColor(_COR_DOURADO)
canvas.drawString(texto_x, centro_y - 0.28 * cm, f"Competência {competencia_label}")
canvas.setFont("Helvetica", 8)
canvas.setFillColor(_COR_DOURADO_CLARO_BANNER)
canvas.drawString(texto_x, centro_y - 0.62 * cm, f"Gerado em {gerado_em}")
def _desenha_rodape(canvas: Canvas) -> None:
canvas.setStrokeColor(_COR_DOURADO)
canvas.setLineWidth(0.6)
canvas.line(_MARGEM, 1.5 * cm, _LARGURA_PAGINA - _MARGEM, 1.5 * cm)
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(_COR_TEXTO_MUTED)
canvas.drawCentredString(_LARGURA_PAGINA / 2, 1.15 * cm, "De Paula Contadores")
def _pagina(canvas: Canvas, doc: SimpleDocTemplate, nome_empresa: str, competencia_label: str, gerado_em: str) -> None:
canvas.saveState()
_desenha_banner(canvas, nome_empresa, competencia_label, gerado_em)
_desenha_rodape(canvas)
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(_COR_TEXTO_MUTED)
canvas.drawRightString(_LARGURA_PAGINA - _MARGEM, 1.15 * cm, f"Página {doc.page}")
canvas.restoreState()
def _estilos() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet()
return {
"h2": ParagraphStyle(
"resumo_h2", parent=base["Heading2"], textColor=_COR_ROXO_ESCURO, fontSize=13, spaceBefore=14, spaceAfter=8
),
"normal": ParagraphStyle("resumo_normal", parent=base["Normal"], fontSize=9.5, textColor=_COR_TEXTO, leading=13),
"bullet": ParagraphStyle("resumo_bullet", parent=base["Normal"], fontSize=9.5, textColor=_COR_TEXTO, leading=13),
"muted": ParagraphStyle("resumo_muted", parent=base["Normal"], fontSize=8, textColor=_COR_TEXTO_MUTED, leading=11),
"titulo_item": ParagraphStyle(
"resumo_titulo_item",
parent=base["Normal"],
fontSize=9.5,
textColor=_COR_ROXO_ESCURO,
fontName="Helvetica-Bold",
leading=12,
),
"vazio": ParagraphStyle(
"resumo_vazio", parent=base["Normal"], fontSize=9, textColor=_COR_TEXTO_MUTED, fontName="Helvetica-Oblique"
),
}
def _inline_markup(tag: Any) -> str:
"""Reconstrói o innerHTML de um elemento só com a marcação que o
`Paragraph` do reportlab entende nativamente (`<b>`/`<i>`/`<u>`/`<br/>`)
— mesmo allowlist fechado de `RICHTEXT_ALLOWED_TAGS` (`serializers.py`:
p/br/div/b/strong/i/em/u/ul/ol/li/img), então não precisa lidar com
nada fora dele. `<img>` é ignorada aqui de propósito — vira um flowable
`Image` próprio, ver `_extrai_imagens()`."""
partes = []
for filho in tag.contents:
if isinstance(filho, bs4.NavigableString):
partes.append(escape(str(filho)))
elif filho.name == "br":
partes.append("<br/>")
elif filho.name in ("b", "strong"):
partes.append(f"<b>{_inline_markup(filho)}</b>")
elif filho.name in ("i", "em"):
partes.append(f"<i>{_inline_markup(filho)}</i>")
elif filho.name == "u":
partes.append(f"<u>{_inline_markup(filho)}</u>")
elif filho.name == "img":
continue
else:
partes.append(_inline_markup(filho))
return "".join(partes)
def _imagem_flowable(tag_img: Any) -> Image | None:
"""`<img src="data:image/...;base64,...">` — a única forma de imagem que
o editor do Resumo do Fechamento produz (colar/arrastar arquivo, ver
`insertImageFile()` em `dashboard-contabil.js`). Qualquer outra coisa
(src externo, ou decodificação inválida) é ignorada silenciosamente —
o texto ao redor continua aparecendo normalmente."""
src = tag_img.get("src", "")
if not src.startswith("data:image"):
return None
try:
_cabecalho, b64 = src.split(",", 1)
dados = base64.b64decode(b64)
with PILImage.open(io.BytesIO(dados)) as img:
largura_px, altura_px = img.size
largura = min(_LARGURA_UTIL, largura_px * 0.75)
altura = largura * (altura_px / largura_px)
return Image(io.BytesIO(dados), width=largura, height=altura)
except Exception:
return None
def _resumo_fechamento_flowables(html: str, estilos: dict[str, ParagraphStyle]) -> list[Any]:
"""Converte o texto rico do Resumo do Fechamento (já sanitizado por
`nh3`) em flowables do reportlab — parágrafos, listas e imagens
embutidas, na ordem em que aparecem."""
if not html or not html.strip():
return []
soup = bs4.BeautifulSoup(html, "html.parser")
flowables: list[Any] = []
for elemento in soup.contents:
if isinstance(elemento, bs4.NavigableString):
if elemento.strip():
flowables.append(Paragraph(escape(str(elemento)), estilos["normal"]))
continue
nome = elemento.name
if nome in ("p", "div"):
for img_tag in elemento.find_all("img"):
imagem = _imagem_flowable(img_tag)
if imagem:
flowables.append(imagem)
flowables.append(Spacer(1, 0.2 * cm))
texto = _inline_markup(elemento)
if texto.strip():
flowables.append(Paragraph(texto, estilos["normal"]))
flowables.append(Spacer(1, 0.15 * cm))
elif nome in ("ul", "ol"):
itens = [ListItem(Paragraph(_inline_markup(li), estilos["bullet"])) for li in elemento.find_all("li", recursive=False)]
if itens:
flowables.append(ListFlowable(itens, bulletType="bullet" if nome == "ul" else "1", leftIndent=14))
flowables.append(Spacer(1, 0.15 * cm))
elif nome == "img":
imagem = _imagem_flowable(elemento)
if imagem:
flowables.append(imagem)
flowables.append(Spacer(1, 0.2 * cm))
elif nome == "br":
flowables.append(Spacer(1, 0.1 * cm))
else:
texto = _inline_markup(elemento)
if texto.strip():
flowables.append(Paragraph(texto, estilos["normal"]))
flowables.append(Spacer(1, 0.15 * cm))
return flowables
def _tabela_indicadores(grupo: dict[str, Any], estilos: dict[str, ParagraphStyle]) -> Table:
dados = [[grupo["nome"], ""]] + [[card["nome"], card["valor_formatado"]] for card in grupo["cards"]]
tabela = Table(dados, colWidths=[_LARGURA_UTIL - 5 * cm, 5 * cm])
estilo = [
("SPAN", (0, 0), (-1, 0)),
("BACKGROUND", (0, 0), (-1, 0), _COR_DOURADO),
("TEXTCOLOR", (0, 0), (-1, 0), _COR_ROXO_ESCURO),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10.5),
("TOPPADDING", (0, 0), (-1, 0), 7),
("BOTTOMPADDING", (0, 0), (-1, 0), 7),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTNAME", (1, 1), (1, -1), "Helvetica-Bold"),
("FONTSIZE", (0, 1), (-1, -1), 9.5),
("TEXTCOLOR", (0, 1), (-1, -1), _COR_TEXTO),
("ALIGN", (1, 0), (1, -1), "RIGHT"),
("LINEBELOW", (0, 1), (-1, -2), 0.5, _COR_LINHA),
("TOPPADDING", (0, 1), (-1, -1), 5),
("BOTTOMPADDING", (0, 1), (-1, -1), 5),
("BOX", (0, 0), (-1, -1), 0.75, _COR_DOURADO),
]
for i in range(1, len(dados)):
if i % 2 == 0:
estilo.append(("BACKGROUND", (0, i), (-1, i), _COR_ZEBRA))
tabela.setStyle(TableStyle(estilo))
return tabela
_PREFIXO_ALVO = {"conta": "Balancete", "dre": "D.R.E.", "analise_vertical": "Análise Vertical"}
def _texto_com_quebras(texto: str) -> str:
return escape(texto).replace("\n", "<br/>")
def _observacoes_flowables(
apuracao: Any, dados_resumo: dict[str, Any], estilos: dict[str, ParagraphStyle]
) -> list[Any]:
flowables: list[Any] = []
for obs in dados_resumo["observacoes_visiveis"]:
prefixo = _PREFIXO_ALVO.get(obs.alvo_tipo, "")
if obs.alvo_tipo == "conta":
titulo = f"{prefixo} — {obs.alvo_chave} {obs.alvo_rotulo}"
else:
titulo = f"{prefixo} — {obs.alvo_rotulo}"
assinatura = f"{obs.criado_por.nome if obs.criado_por else 'Equipe contábil'} · {obs.criado_em:%d/%m/%Y}"
if obs.competencia_origem != apuracao.competencia:
assinatura += f" · competência {obs.competencia_origem:%m/%Y}"
flowables.append(Paragraph(escape(titulo), estilos["titulo_item"]))
flowables.append(Paragraph(_texto_com_quebras(obs.texto), estilos["normal"]))
flowables.append(Paragraph(escape(assinatura), estilos["muted"]))
flowables.append(Spacer(1, 0.28 * cm))
for achado in dados_resumo["achados_com_observacao"]:
titulo = f"Auditoria — {achado.titulo}"
if achado.tratado_por:
titulo += f" — {achado.tratado_por.nome}"
flowables.append(Paragraph(escape(titulo), estilos["titulo_item"]))
flowables.append(Paragraph(_texto_com_quebras(achado.observacao_contador), estilos["normal"]))
flowables.append(Spacer(1, 0.28 * cm))
if not flowables:
flowables.append(Paragraph("Nenhuma observação registrada nesta análise.", estilos["vazio"]))
return flowables
def gera_pdf_resumo(apuracao: Any, dados_resumo: dict[str, Any], gerado_em: str) -> bytes:
"""`apuracao` é a `ContabilApuracao`; `dados_resumo` é o dict devolvido
por `_contabil_dados_resumo()` (`views.py`) — mesmo conteúdo da aba
"Resumo" do relatório HTML "Gerar Dashboard", sem o resto do relatório
(Balancete/D.R.E./Análise Vertical)."""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
topMargin=_ALTURA_BANNER + 0.8 * cm,
bottomMargin=2.0 * cm,
leftMargin=_MARGEM,
rightMargin=_MARGEM,
title=f"Resumo do Fechamento - {apuracao.nome_empresa}",
)
estilos = _estilos()
story: list[Any] = []
if apuracao.resumo_fechamento:
story.append(Paragraph("Resumo do Fechamento", estilos["h2"]))
story.extend(_resumo_fechamento_flowables(apuracao.resumo_fechamento, estilos))
story.append(Spacer(1, 0.3 * cm))
for grupo in dados_resumo["indicadores_grupos"]:
# Sem Paragraph de título separado aqui — `_tabela_indicadores()` já
# usa o nome do grupo como a própria barra de cabeçalho da tabela
# (mesmo padrão de `_tabela_secao()` em custo_contratacao/pdf.py).
story.append(_tabela_indicadores(grupo, estilos))
story.append(Spacer(1, 0.4 * cm))
story.append(Paragraph("Observações da Análise", estilos["h2"]))
story.extend(_observacoes_flowables(apuracao, dados_resumo, estilos))
def _desenha_pagina(canvas: Canvas, doc: SimpleDocTemplate) -> None:
competencia: date = apuracao.competencia
_pagina(canvas, doc, apuracao.nome_empresa, f"{competencia:%m/%Y}", gerado_em)
doc.build(story, onFirstPage=_desenha_pagina, onLaterPages=_desenha_pagina)
return buffer.getvalue()