"""Geração do recibo em PDF de um colaborador, a partir dos dados já
calculados/revisados (não reprocessa nada — só formata o que está salvo).
Layout inspirado na planilha antiga do escritório (ver anexo do usuário),
com a identidade visual do Portal (logo + paleta roxo/dourado, ver
tokens.css) — mesma paleta do tema claro, já que o PDF é sempre impresso
em fundo branco."""
from __future__ import annotations
import calendar
import io
import threading
from decimal import Decimal
from django.conf import settings
from PIL import Image as PILImage
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import Image, KeepTogether, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from portal_api.indicadores.calculo import composicao_individual, percentual_vigente
from portal_api.models import (
RESPOSTA_CRITERIO_INDICADOR_CHOICES,
IndicadorApuracao,
IndicadorApuracaoColaborador,
IndicadorCriterio,
TIPO_COLABORADOR_INDICADOR_CHOICES,
)
# Versão curta só pra coluna "Tipo" da tabela de Empresas — o label completo
# ("Contador (com conciliador)") não cabe na coluna estreita mesmo já
# quebrando linha (Paragraph); mesma abreviação "Contador SC/CC" já usada
# informalmente pra esses dois tipos em CLAUDE.md.
TIPO_LABEL_CURTO = {
"1": "Contábil+Fiscal",
"2": "Contador SC",
"3": "Contador CC",
"4": "Fiscal",
"5": "Conciliador",
}
RESPOSTA_LABEL = dict(RESPOSTA_CRITERIO_INDICADOR_CHOICES)
PERIODO_LABEL = {
IndicadorCriterio.PERIODO_TODOS: "Todos os meses",
IndicadorCriterio.PERIODO_MAR_A_NOV: "Março a Novembro",
IndicadorCriterio.PERIODO_DEZ_A_JAN: "Dezembro a Janeiro",
}
MES_LABEL = {
1: "janeiro", 2: "fevereiro", 3: "março", 4: "abril", 5: "maio", 6: "junho",
7: "julho", 8: "agosto", 9: "setembro", 10: "outubro", 11: "novembro", 12: "dezembro",
}
CIDADE_ESCRITORIO = "Foz do Iguaçu"
LOGO_PATH = settings.BASE_DIR / "static" / "img" / "logo.png"
# `logo.png` é 6250×4419px (~480KB) — pensado pra tela, não pra um logo de
# ~3,6cm impresso num PDF. Embutir o arquivo original em cada recibo (um ZIP
# chega a ter 40+ colaboradores) inflava o ZIP pra ~19MB e deixava o download
# lento/travado no navegador. Redimensiona uma vez por processo (cache em
# memória, protegido por lock — vários recibos podem ser gerados em paralelo
# dentro do mesmo worker) pra um tamanho generoso pra impressão (600px de
# largura, ~423 DPI num logo de 3,6cm) e reaproveita os bytes já reduzidos em
# todos os recibos daquele ZIP.
_logo_bytes_cache: bytes | None = None
_logo_bytes_lock = threading.Lock()
def _logo_bytes() -> bytes | None:
global _logo_bytes_cache
if _logo_bytes_cache is not None:
return _logo_bytes_cache or None
with _logo_bytes_lock:
if _logo_bytes_cache is None:
try:
imagem = PILImage.open(LOGO_PATH)
imagem.thumbnail((600, 600))
buffer = io.BytesIO()
imagem.save(buffer, format="PNG", optimize=True)
_logo_bytes_cache = buffer.getvalue()
except Exception:
_logo_bytes_cache = b""
return _logo_bytes_cache or None
# Paleta do escritório (mesmos valores do tema claro em tokens.css) — o PDF é
# sempre impresso/visualizado em fundo branco, então usa as cores pensadas
# pra contraste em fundo claro, não as do tema escuro (padrão da tela).
COR_ROXO_ESCURO = colors.HexColor("#3d2178")
COR_ROXO_CLARO = colors.HexColor("#efe8ff")
COR_DOURADO = colors.HexColor("#b4872a")
COR_DOURADO_CLARO = colors.HexColor("#f6ecd4")
COR_TEXTO = colors.HexColor("#211d2b")
COR_TEXTO_MUTED = colors.HexColor("#5f5a6d")
COR_BORDA = colors.HexColor("#e4e0ec")
COR_SUCESSO_BG = colors.HexColor("#dcf5e3")
COR_SUCESSO_TEXTO = colors.HexColor("#1a7a3d")
COR_FALHA_BG = colors.HexColor("#fbe2e2")
COR_FALHA_TEXTO = colors.HexColor("#b3261e")
COR_NEUTRO_BG = colors.HexColor("#ece9f2")
COR_NEUTRO_TEXTO = colors.HexColor("#5f5a6d")
COR_ATINGIDO_BG = {
"SIM": COR_SUCESSO_BG,
"NAO": COR_FALHA_BG,
"NAO_FAZ": COR_FALHA_BG,
"NAO_SE_APLICA": COR_NEUTRO_BG,
}
COR_ATINGIDO_TEXTO = {
"SIM": COR_SUCESSO_TEXTO,
"NAO": COR_FALHA_TEXTO,
"NAO_FAZ": COR_FALHA_TEXTO,
"NAO_SE_APLICA": COR_NEUTRO_TEXTO,
}
def _moeda(valor: Decimal) -> str:
# ` ` (não `" "`) entre "R$" e o número — todo valor aqui vira
# Paragraph numa coluna estreita, e um espaço normal é um ponto de quebra
# de linha válido pro word-wrap do reportlab: com espaço comum, um valor
# que não coubesse numa linha só quebrava bem ali, deixando "R$" sozinho
# numa linha acima do número. Não-breaking space mantém os dois sempre
# juntos, movendo a quebra (se precisar) pra dentro do próprio número.
texto = f"{valor:,.2f}"
return "R$ " + texto.replace(",", "_").replace(".", ",").replace("_", ".")
def _percentual(valor: Decimal) -> str:
texto = f"{Decimal(valor):,.2f}"
return texto.replace(",", "_").replace(".", ",").replace("_", ".") + "%"
def _respostas_aplicaveis(colaborador: IndicadorApuracaoColaborador, periodo_atual: str) -> list:
"""Mesma regra de aplicabilidade de `calculo._respostas_do_grupo` (período
da competência + papel do colaborador pros critérios Individual) — só que
aqui olhando os 3 níveis juntos (Individual/Grupo/Departamento), pra
reproduzir o agrupamento por período da planilha antiga (ver docstring do
módulo)."""
tipos_do_colaborador = {empresa.tipo for empresa in colaborador.empresas.all()}
aplicaveis = []
for resposta in colaborador.respostas.all():
criterio = resposta.criterio
if criterio.periodo not in (IndicadorCriterio.PERIODO_TODOS, periodo_atual):
continue
if criterio.grupo == IndicadorCriterio.GRUPO_INDIVIDUAL and criterio.papel_aplicavel:
if criterio.papel_aplicavel not in tipos_do_colaborador:
continue
aplicaveis.append(resposta)
return aplicaveis
def _agrupa_por_periodo(respostas: list) -> list[tuple[str, list]]:
""""Todos os meses" sempre primeiro, depois o período sazonal ativo —
mesma ordem da planilha antiga."""
grupos: dict[str, list] = {}
for resposta in respostas:
grupos.setdefault(resposta.criterio.periodo, []).append(resposta)
ordem = [IndicadorCriterio.PERIODO_TODOS] + [
periodo for periodo in grupos if periodo != IndicadorCriterio.PERIODO_TODOS
]
return [(periodo, grupos[periodo]) for periodo in ordem if periodo in grupos]
def _estilos():
base = getSampleStyleSheet()
return {
"titulo": ParagraphStyle(
"ind_titulo", parent=base["Heading1"], alignment=TA_LEFT, fontSize=13,
textColor=COR_ROXO_ESCURO, spaceAfter=2,
),
"subtitulo": ParagraphStyle(
"ind_subtitulo", parent=base["Normal"], alignment=TA_LEFT, fontSize=9, textColor=COR_TEXTO_MUTED,
),
"normal": ParagraphStyle("ind_normal", parent=base["Normal"], fontSize=9, textColor=COR_TEXTO),
"secao": ParagraphStyle(
"ind_secao", parent=base["Heading2"], fontSize=10.5, textColor=COR_ROXO_ESCURO,
spaceBefore=10, spaceAfter=4,
),
"banner_label": ParagraphStyle(
"ind_banner_label", parent=base["Normal"], fontSize=9, textColor=colors.white, alignment=TA_LEFT,
),
"banner_valor": ParagraphStyle(
"ind_banner_valor", parent=base["Normal"], fontSize=20, textColor=colors.white,
fontName="Helvetica-Bold", alignment=TA_RIGHT,
),
"rodape": ParagraphStyle("ind_rodape", parent=base["Normal"], fontSize=9, alignment=TA_CENTER, textColor=COR_TEXTO),
"celula": ParagraphStyle("ind_celula", parent=base["Normal"], fontSize=7.2, textColor=COR_TEXTO, leading=8.6),
"celula_centro": ParagraphStyle(
"ind_celula_centro", parent=base["Normal"], fontSize=7.2, leading=8.6,
textColor=COR_TEXTO, alignment=TA_CENTER,
),
"celula_direita": ParagraphStyle(
"ind_celula_direita", parent=base["Normal"], fontSize=7.2, leading=8.6,
textColor=COR_TEXTO, alignment=TA_RIGHT,
),
"celula_negrito": ParagraphStyle(
"ind_celula_negrito", parent=base["Normal"], fontSize=7.6, leading=9,
textColor=COR_TEXTO, fontName="Helvetica-Bold",
),
"celula_direita_negrito": ParagraphStyle(
"ind_celula_direita_negrito", parent=base["Normal"], fontSize=7.6, leading=9,
textColor=COR_TEXTO, fontName="Helvetica-Bold", alignment=TA_RIGHT,
),
"cabecalho_tabela": ParagraphStyle(
"ind_cabecalho_tabela", parent=base["Normal"], fontSize=7.2, leading=8.4,
textColor=colors.white, fontName="Helvetica-Bold", alignment=TA_CENTER,
),
}
def _cabecalho(apuracao: IndicadorApuracao, estilos: dict) -> Table:
logo_bytes = _logo_bytes()
if logo_bytes:
logo = Image(io.BytesIO(logo_bytes), width=3.6 * cm, height=3.6 * cm / 1.41)
else:
logo = Paragraph("De Paula Contadores", estilos["titulo"])
titulo = [
Paragraph("PLANILHA DE APURAÇÃO DO INDICADOR DE DESEMPENHO", estilos["titulo"]),
Paragraph("Fiscontábil — Geradoc", estilos["subtitulo"]),
Paragraph(f"Competência: {apuracao.competencia:%m/%Y}", estilos["subtitulo"]),
]
tabela = Table([[logo, titulo]], colWidths=[4 * cm, None])
tabela.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (0, 0), "LEFT"),
("LINEBELOW", (0, 0), (-1, 0), 1.5, COR_DOURADO),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
return tabela
def _banner_percentual(colaborador: IndicadorApuracaoColaborador, estilos: dict) -> Table:
# O banner sempre mostra o percentual **efetivo/medido** (a composição
# dos 3 níveis), independente de qualquer ajuste manual — o valor que a
# Direção decidiu pagar (quando diferente) aparece à parte, na linha de
# detalhe abaixo, pra deixar claro os dois números: o que o colaborador
# atingiu e o que foi de fato considerado.
composicao = composicao_individual(colaborador)
percentual_medido = composicao["total_calculado"]
linha_titulo = Table(
[
[
Paragraph("PERCENTUAL DO INDICADOR INDIVIDUAL", estilos["banner_label"]),
Paragraph(_percentual(percentual_medido), estilos["banner_valor"]),
]
],
colWidths=[None, 5 * cm],
)
linha_titulo.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), COR_ROXO_ESCURO),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (0, 0), 12),
("RIGHTPADDING", (-1, 0), (-1, 0), 12),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
linhas_detalhe = [
f"Grupo: {_percentual(colaborador.pct_grupo)}{' (ajustado manualmente)' if colaborador.pct_grupo_ajustado_manualmente else ''}",
f"Departamento: {_percentual(colaborador.pct_departamento)}{' (ajustado manualmente)' if colaborador.pct_departamento_ajustado_manualmente else ''}",
]
if colaborador.pct_individual_ajustado_manualmente:
linhas_detalhe.insert(
0, f"Percentual individual ajustado pela direção: {_percentual(colaborador.pct_individual)}."
)
subtexto = Paragraph(" · ".join(linhas_detalhe), estilos["normal"])
return KeepTogether([linha_titulo, Spacer(1, 0.15 * cm), subtexto])
def _tabela_empresas(colaborador: IndicadorApuracaoColaborador, estilos: dict) -> Table:
# Toda célula (menos o cabeçalho, já em Paragraph) precisa ser um
# Paragraph, não uma string solta — string solta não quebra linha dentro
# da coluna, e com ALIGN à direita/centro um valor mais largo que a
# coluna (ex.: "Contador (com conciliador)" em Tipo, ou os totais em
# negrito, que ficam mais largos que a mesma string em peso normal)
# simplesmente vaza por cima da célula vizinha em vez de quebrar linha.
empresas = list(colaborador.empresas.all())
cabecalho = ["Código", "Empresa", "Honorário", "Hon. Ajustado", "Tipo", "Indiv.", "Grupo", "Depto.", "Total"]
dados = [[Paragraph(texto, estilos["cabecalho_tabela"]) for texto in cabecalho]]
for empresa in empresas:
nome = empresa.nome_empresa or empresa.codigo_empresa
if empresa.honorario_nao_encontrado:
nome += " (honorário não encontrado)"
dados.append(
[
Paragraph(empresa.codigo_empresa, estilos["celula_centro"]),
Paragraph(nome, estilos["celula"]),
Paragraph(_moeda(empresa.honorario), estilos["celula_direita"]),
Paragraph(_moeda(empresa.honorario_ajustado), estilos["celula_direita"]),
Paragraph(TIPO_LABEL_CURTO.get(empresa.tipo, empresa.tipo), estilos["celula_centro"]),
Paragraph(_moeda(empresa.valor_individual), estilos["celula_direita"]),
Paragraph(_moeda(empresa.valor_grupo), estilos["celula_direita"]),
Paragraph(_moeda(empresa.valor_departamento), estilos["celula_direita"]),
Paragraph(_moeda(empresa.valor_total), estilos["celula_direita"]),
]
)
dados.append(
[
"",
Paragraph("Total do Indicador", estilos["celula_negrito"]),
"",
"",
"",
Paragraph(_moeda(sum((empresa.valor_individual for empresa in empresas), Decimal("0"))), estilos["celula_direita_negrito"]),
Paragraph(_moeda(sum((empresa.valor_grupo for empresa in empresas), Decimal("0"))), estilos["celula_direita_negrito"]),
Paragraph(_moeda(sum((empresa.valor_departamento for empresa in empresas), Decimal("0"))), estilos["celula_direita_negrito"]),
Paragraph(_moeda(colaborador.valor_total), estilos["celula_direita_negrito"]),
]
)
tabela = Table(
dados,
repeatRows=1,
colWidths=[1.0 * cm, 3.3 * cm, 2.0 * cm, 2.0 * cm, 2.1 * cm, 1.7 * cm, 1.6 * cm, 1.9 * cm, 1.7 * cm],
)
estilo = [
("BACKGROUND", (0, 0), (-1, 0), COR_ROXO_ESCURO),
("FONTSIZE", (0, 0), (-1, -1), 7.6),
("GRID", (0, 0), (-1, -1), 0.5, COR_BORDA),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
# Padding lateral padrão do reportlab é 6pt de cada lado — generoso
# demais pra colunas numéricas estreitas; reduzido pra sobrar mais
# espaço de verdade pro texto sem precisar alargar a tabela inteira.
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, COR_ROXO_CLARO]),
("BACKGROUND", (0, -1), (-1, -1), COR_DOURADO_CLARO),
("LINEABOVE", (0, -1), (-1, -1), 1, COR_DOURADO),
]
tabela.setStyle(TableStyle(estilo))
return tabela
def _tabela_indicadores(colaborador: IndicadorApuracaoColaborador, apuracao: IndicadorApuracao, estilos: dict) -> Table:
periodo_atual = apuracao.periodo_criterio()
grupos = _agrupa_por_periodo(_respostas_aplicaveis(colaborador, periodo_atual))
dados = [["Atingido?", "Critério", "Peso", "% Medido"]]
estilo = [
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.5, COR_BORDA),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("ALIGN", (0, 0), (0, -1), "CENTER"),
("ALIGN", (2, 0), (3, -1), "CENTER"),
("BACKGROUND", (0, 0), (-1, 0), COR_ROXO_ESCURO),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
]
linha = 1
for periodo, respostas in grupos:
dados.append([PERIODO_LABEL.get(periodo, periodo), "", "", ""])
estilo.append(("SPAN", (0, linha), (3, linha)))
estilo.append(("BACKGROUND", (0, linha), (-1, linha), COR_DOURADO_CLARO))
estilo.append(("FONTNAME", (0, linha), (-1, linha), "Helvetica-Bold"))
estilo.append(("TEXTCOLOR", (0, linha), (-1, linha), COR_ROXO_ESCURO))
estilo.append(("ALIGN", (0, linha), (-1, linha), "LEFT"))
linha += 1
for resposta in respostas:
dados.append(
[
RESPOSTA_LABEL.get(resposta.valor, resposta.valor),
Paragraph(resposta.criterio.nome, estilos["celula"]),
_percentual(resposta.criterio.peso),
_percentual(resposta.percentual_calculado) if resposta.percentual_calculado is not None else "—",
]
)
cor_bg = COR_ATINGIDO_BG.get(resposta.valor, COR_NEUTRO_BG)
cor_texto = COR_ATINGIDO_TEXTO.get(resposta.valor, COR_NEUTRO_TEXTO)
estilo.append(("BACKGROUND", (0, linha), (0, linha), cor_bg))
estilo.append(("TEXTCOLOR", (0, linha), (0, linha), cor_texto))
estilo.append(("FONTNAME", (0, linha), (0, linha), "Helvetica-Bold"))
linha += 1
tabela = Table(dados, colWidths=[2 * cm, 11.4 * cm, 1.8 * cm, 2 * cm])
tabela.setStyle(TableStyle(estilo))
return tabela
def _tabela_percentuais_por_tipo(apuracao: IndicadorApuracao, departamento_id: int | None) -> Table:
dados = [["Cód.", "Tipo", "% Individual", "% Grupo", "% Depto."]]
estilo = [
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.5, COR_BORDA),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("ALIGN", (0, 0), (0, -1), "CENTER"),
("ALIGN", (2, 0), (-1, -1), "CENTER"),
("BACKGROUND", (0, 0), (-1, 0), COR_ROXO_ESCURO),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, COR_ROXO_CLARO]),
]
for tipo, label in TIPO_COLABORADOR_INDICADOR_CHOICES:
percentual = percentual_vigente(tipo, apuracao.competencia, departamento_id)
if percentual is None:
continue
dados.append(
[
tipo,
label,
_percentual(percentual.percentual_individual),
_percentual(percentual.percentual_grupo),
_percentual(percentual.percentual_departamento),
]
)
tabela = Table(dados, colWidths=[1.5 * cm, 5.5 * cm, 3 * cm, 3 * cm, 3 * cm])
tabela.setStyle(TableStyle(estilo))
return tabela
def _rodape(colaborador: IndicadorApuracaoColaborador, apuracao: IndicadorApuracao, estilos: dict) -> list:
ultimo_dia = calendar.monthrange(apuracao.competencia.year, apuracao.competencia.month)[1]
data_extenso = f"{CIDADE_ESCRITORIO}, {ultimo_dia} de {MES_LABEL[apuracao.competencia.month]} de {apuracao.competencia.year}."
return [
Spacer(1, 1 * cm),
Paragraph(data_extenso, estilos["rodape"]),
Spacer(1, 1.4 * cm),
Paragraph("DE ACORDO,", estilos["rodape"]),
Spacer(1, 1.3 * cm),
Paragraph("_" * 45, estilos["rodape"]),
Paragraph(f"{colaborador.nome}", estilos["rodape"]),
]
def gera_pdf_recibo(colaborador: IndicadorApuracaoColaborador, apuracao: IndicadorApuracao) -> bytes:
buffer = io.BytesIO()
documento = SimpleDocTemplate(
buffer, pagesize=A4, topMargin=1.3 * cm, bottomMargin=1.3 * cm, leftMargin=1.5 * cm, rightMargin=1.5 * cm
)
estilos = _estilos()
elementos = [
_cabecalho(apuracao, estilos),
Spacer(1, 0.35 * cm),
Paragraph(
f"Nome: {colaborador.nome} · Gerente: {colaborador.gerente or '-'} "
f" · Departamento: "
f"{colaborador.departamento.nome if colaborador.departamento_id else '-'}",
estilos["normal"],
),
Spacer(1, 0.3 * cm),
_banner_percentual(colaborador, estilos),
Spacer(1, 0.5 * cm),
Paragraph("Empresas", estilos["secao"]),
_tabela_empresas(colaborador, estilos),
Spacer(1, 0.3 * cm),
Paragraph(f"Total do Indicador de Desempenho: {_moeda(colaborador.valor_total)}", estilos["normal"]),
Paragraph("Critérios do Indicador", estilos["secao"]),
_tabela_indicadores(colaborador, apuracao, estilos),
Paragraph("Percentuais por Tipo de Colaborador", estilos["secao"]),
_tabela_percentuais_por_tipo(apuracao, colaborador.departamento_id),
]
elementos.extend(_rodape(colaborador, apuracao, estilos))
documento.build(elementos)
return buffer.getvalue()