"""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()