345 lines
15 KiB
Python
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()
|