93 KiB
Relatório Contábil (Relatórios > Contabilidade)
Este arquivo é carregado automaticamente ao trabalhar dentro de
portal_api/dashboard_contabil/. VerCLAUDE.mdna raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.).Este arquivo descreve o estado atual, não o histórico. Nenhuma seção aqui é datada por rodada e nenhuma narra "antes era X, agora é Y" — quando o motivo de uma decisão importa para não a reverter por engano, ele aparece como motivo, não como cronologia. O histórico rodada a rodada (92 a 148) está em
CHANGELOG.mdnesta mesma pasta. Ao implementar algo novo aqui, atualizar os dois: o estado atual neste arquivo, a mudança no changelog.
Nome: "Relatório Contábil" é o rótulo visível ao usuário; internamente tudo continua dashboard_contabil/dashboard-contabil.*/Contabil*//api/contabil-*/apps["dashboard-contabil"]. O rename foi só de texto visível (menu em catalogo.py, <title>/<h1>/cabeçalhos dos dois templates, o botão "Gerar Relatório" e os verbose_name do admin) — pedido explícito do usuário, para a ferramenta soar como um aliado do trabalho do contador em vez de mais um sistema. Não propagar esse rename para dentro do código.
Ferramenta que otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em ITD-FISCO-7513, "Roteiro de Conferência de Balancete"): o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório já enviado ao cliente, gerado pelo Questor ou pelo Contabit, ver "Leiaute Contabit" abaixo), a ferramenta extrai as contas/linhas, roda um motor de regras de auditoria e apresenta os apontamentos numa tela de revisão, onde o contador analisa, registra observações e conclui a análise. No fim, gera um relatório HTML autocontido para o cliente.
Permissão: toggle único apps["dashboard-contabil"] em permissoes["relatorios"] (subgrupo "Contabilidade"), checado via PermissaoApp("relatorios", "dashboard-contabil") em todos os ViewSets. Nasce restrita ao perfil "Inovação" (override em seed_portal.py), já que expõe balancete/DRE completos dos clientes.
Decisões de escopo (confirmadas com o usuário)
- Dois modelos de PDF, detectados sozinhos: Questor e Contabit. O usuário nunca escolhe;
ContabilApuracao.leiauteguarda o detectado. Ver "Leiaute Contabit" abaixo para o que muda. - Entrada: só PDF. O Questor também exporta Balancete/DRE em XLSX estruturado (mais confiável de extrair), levantado como alternativa e recusado pelo usuário. Não há suporte a XLSX na entrada.
- Sempre uma apuração por vez. Sem consolidação entre empresas ou competências — isso seria um BI à parte. O BI Contábil externo que esta ferramenta substitui tem filtros "Ano-Mês"/"Empresa-Filial" que sugerem o contrário; o escopo confirmado é uma apuração por vez.
- O histórico para comparação mês a mês fica no próprio Portal. Cada apuração processada fica salva (
ContabilApuracao, chave naturalcodigo_empresa+competencia), e é contra ela que se compara. Não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal", que é um relatório complementar enviado ao cliente por fora, não uma entrada desta ferramenta. - As regras só usam o que é derivável do próprio Balancete/DRE anexado. Nenhuma checagem do ITD que dependa de sistema externo (Questor, extratos bancários, folha, PID legado). A ferramenta é analítica, não substitui as etapas operacionais do roteiro (zeramento de saldos etc.).
- "Auditoria de Balancetes" (Auditorias > Fisco/Contábil) é outra coisa — placeholder
href="#"no menu que referencia o antigo sistema PID legado. Não confundir com esta ferramenta.
Extração do PDF (parser.py)
O relatório Questor tem uma particularidade de renderização que quebra a extração padrão do pdfplumber: cada caractere é desenhado em posição própria (sem kerning) e, por baixo, o PDF inclui uma grade densa de caracteres de espaço cobrindo toda a largura de cada linha — artefato do gerador, não intencional. Isso faz page.extract_words()/extract_text() tratarem esses espaços de fundo como separadores reais, quebrando números em dígitos isolados ("34.245.469,57" vira '3', '4', '.', '2'...).
_reconstroi_linhas() contorna trabalhando direto com page.chars: ignora todo caractere igual a " " e reconstrói cada linha pela posição real (x0/x1) dos não-espaços, reinserindo um único espaço só quando o vão horizontal entre dois caracteres consecutivos passa de _GAP_ESPACO (0.8pt). Calibrado contra 792 - balancete 072026.pdf (arquivo de referência, fora do repositório, em Projetos\Balancetes): o vão dentro de uma palavra/número é ~0, entre palavras da mesma descrição ~1.7-1.9pt, entre campos da tabela sempre ≥5pt. Validado rodando contra o PDF real antes de escrever o parser definitivo — nunca desenhar regex só a partir de texto colado, ver [[feedback_pdf_parser_precisa_arquivo_real]] na memória.
extrai_balancete_dre(origem) aceita caminho em disco ou arquivo já aberto em memória (io.BytesIO) — a view chama isto antes de salvar qualquer coisa, já que codigo_empresa/competencia só são conhecidos depois de ler o PDF.
As três seções
- Balancete: cada linha casa com
_RE_LINHA_BALANCETE(^(conta)\s+(S)?\s*(código)\s+(resto)$); os últimos 4 tokens monetários deresto(via_RE_MONETARIO) são Saldo Anterior/Débito/Crédito/Saldo Atual, nessa ordem, e o texto antes deles é a descrição.tipoé"S"(sintética) quando o flag aparece,"A"(analítica) quando não. - DRE: descrição + um único valor final, sem código de classificação.
nivel(indentação) vem dox0do primeiro caractere em relação ao menorx0da seção (raiz, nível 0), com divisor/7.0.totalizadoréTruequando algum caractere da linha usa fonte negrito (fontnamecontendo"bold") — confirmado no PDF real: "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usamTimes-Bold, as demaisTimes-Roman. - Demonstração Mensal (Análise Vertical) (páginas finais, quando presentes): mesma árvore da DRE (mesma descrição/ordem/negrito), mas cada linha repete N pares "Valor Variação", um por mês mostrado (normalmente os 3 meses até a competência do PDF). Cada valor já vem isolado por mês, não acumulado desde janeiro como a DRE principal — confirmado comparando os dois:
linhas_drede julho é o YTD jan-jul,linhas_analise_verticalde julho é só julho.percentualé a análise vertical de verdade (percentual da linha sobre a Receita Operacional Bruta daquele mês), não uma variação mês a mês._RE_PAR_VALOR_VARIACAOcasa um par por vez;_RE_MES_ANALISE_VERTICALcaptura o cabeçalho de mês uma única vez (as páginas seguintes repetem o mesmo cabeçalho, ignorado depois da primeira captura). Mesmo divisor/7.0da DRE para o nível.
PDF de fonte atípica (fonte_pdf_atipica)
Nem todo cliente/instalação do Questor embute a mesma fonte. Num PDF real (1751 - Balancete 07.2026.pdf, TAROBA) a fonte perde o til do "Ã" na extração: "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO" sai "DEMONSTRAÇAO..." (só falta o til, o resto do caractere sai certo — não é um replacement character). Como o parser comparava o título por igualdade exata, a seção da DRE nunca era detectada e a criação da apuração devolvia 400 "Nenhuma linha de DRE encontrada no PDF".
_normaliza_titulo() compara o título sem acento (unicodedata.normalize("NFKD", ...) + remoção de acento), com _TITULO_DRE_NORM/_TITULO_ANALISE_VERTICAL_NORM calculados uma vez no import. Mesmo espírito de _RE_PERIODO já aceitar Per[ií]odo para essa mesma classe de variação.
Problema relacionado que não tem correção segura: o mesmo tipo de fonte também gruda palavras em descrições de conta ("DEPÓSITOS BANCÁRIOS A VISTA" → "...BANCÁRIOSA VISTA"). Investigado de verdade, não teoricamente: (1) medindo a distribuição de vãos nesse PDF, o vão dentro de uma palavra (entre "U" e "I" de "EQUIVALENTES") chega a ser maior que um vão real entre duas palavras curtas, então nenhum limiar único separa os dois casos; (2) usar os caracteres de espaço literais do próprio PDF como sinal não funciona, porque a posição vertical deles às vezes arredonda para uma linha diferente da do texto da mesma linha visual; (3) baixar _GAP_ESPACO para 0.3 conserta alguns casos e quebra outros que hoje saem certos (EQUIVALENTES vira EQU IVALENTES). Decisão explícita do usuário: não arriscar. Valores monetários nunca são afetados, só a descrição de algumas contas, então o custo de regressão supera o benefício.
A solução adotada é avisar, não corrigir: ContabilApuracao.fonte_pdf_atipica é True quando _normaliza_titulo() precisou de verdade (o título bateu sem acento mas não bateria com acento) para reconhecer a seção DRE ou Análise Vertical — sinal indireto mas real de que o PDF usa uma fonte diferente da de referência. ResultadoExtracao.fonte_pdf_atipica (modelos.py) carrega o valor; create()/reprocessar() persistem. Exposto em leitura nos dois serializers, sem rota de escrita. O frontend mostra um badge com tooltip ao lado do nome da empresa pedindo atenção redobrada aos nomes de conta; não bloqueia nem oculta nada.
Validado contra os 3 PDFs reais disponíveis: True só para o 1751, False para 792 e 2017 (sem falso positivo nos já confirmados corretos).
Leiaute Contabit (parser_contabit.py)
extrai_balancete_dre(origem, nome_arquivo) abre o PDF e, se parser_contabit.eh_leiaute_contabit(primeira página) (cabeçalho com "Classificação/Conta" e "PERÍODO DE ... À ..."), devolve parser_contabit.extrai(pdf, nome_arquivo); senão segue o caminho do Questor, que não mudou. As conversões (para_decimal/para_percentual/para_data) e os erros vivem em conversao.py só para evitar import circular; parser.py reexporta ExtracaoInvalidaError/CodigoEmpresaAusenteError. Calibrado contra um único arquivo real, 1512 - Balancete 082026.pdf (em Projetos\Balancetes\Contabit, fora do repositório). Decisão do usuário: estruturar com base neste documento e ir testando conforme chegarem outros, então tratar os pontos marcados como premissa com a devida desconfiança.
Código da empresa vem do nome do arquivo (decisão explícita do usuário): o PDF só traz nome e CNPJ. codigo_empresa_do_nome_arquivo() pega o prefixo numérico ("1512 - Balancete 082026.pdf" → "1512", sem padding). Sem prefixo, CodigoEmpresaAusenteError (subclasse de ExtracaoInvalidaError), que a view devolve como 400 com mensagem própria (_contabil_resposta_extracao_invalida()). create()/reprocessar() passam arquivo.name ao pipeline; o Questor ignora o parâmetro.
Balancete lido na ordem do fluxo do PDF, não por posição. A descrição longa é cortada na tela (clip), mas o texto inteiro continua no PDF e seus caracteres ocupam a mesma faixa horizontal do nº da conta e até do Saldo Anterior: ordenar por x0 produz "GRAFICOS2 2L9T3D5A" para "GRAFICOS LTDA" + conta "22935". _trechos_por_fluxo() segue pagina.chars na ordem em que foram desenhados (descrição, depois os valores, depois o nº da conta) e corta um trecho quando o próximo caractere recua mais que o kerning explica (_RECUO_MAXIMO_KERNING) ou salta mais que um espaço (_SALTO_MAXIMO_TRECHO). Trecho só monetário é valor (os 4 são ordenados entre si por x0: Saldo Anterior, Débito, Crédito, Saldo Atual), trecho só dígitos é o nº da conta, o resto é classificação + descrição. Qualquer linha com classificação e contagem de colunas diferente de 4 valores levanta erro em vez de gravar algo desalinhado.
- Sem flag S/A: sintética é a linha sem nº de conta (
conta_numero=None, por issoContabilConta.conta_numeroaceita nulo e a tela mostra vazio). - Saldos pela natureza da conta: Passivo e redutoras (depreciação acumulada, encargos a transcorrer) aparecem positivos, e o grupo pai faz a subtração. Exibidos como no PDF (decisão do usuário), sem converter para a convenção do Questor. Todas as 130 contas do arquivo de referência fecham
saldo anterior ± débito ∓ crédito = saldo atualpela natureza. - Os espaços entre palavras são caracteres reais neste PDF (não a grade de fundo do Questor), então
_texto_por_posicao()os mantém e só colapsa repetições.
DRE ("DEMONSTRAÇÃO DO RESULTADO ACUMULADO NO ANO"): negativo com "-" (não parênteses). O valor das linhas em negrito é desenhado 1pt abaixo do texto, então _agrupa_linhas() junta caracteres com tolerância vertical (_TOLERANCIA_LINHA) em vez de round(top). Nível pelo recuo, passo _PASSO_NIVEL_DRE (7,45pt). A última linha é "LUCRO LIQUIDO DO EXERCICIO".
Demonstração mensal ("DEMONSTRAÇÃO DO RESULTADO / MENSAL") alimenta a mesma Análise Vertical do Questor: meses por extenso ("JUNHO/2026" → "jun/2026", _MESES_ABREVIADOS), AV% sem "%", uma coluna TOTAL a mais (soma dos meses mostrados, descartada) e linhas espaçadoras só com zeros, sem descrição (ignoradas). Passo de nível _PASSO_NIVEL_MENSAL (5,95pt). Os rótulos não são iguais aos da DRE acumulada ("RESULTADO BRUTO" x "LUCRO BRUTO") e linhas zeradas no trimestre são omitidas; nada no código depende das duas árvores serem idênticas.
Regras que mudam por leiaute (todas as outras rodam igual): balanceamento compara Ativo = Passivo (sem somar); caixa negativo usa o prefixo 1.10.10.01 (PREFIXO_CAIXA_CONTABIT); lucro Balancete x DRE lê 2.40.40.20 (CODIGO_LUCRO_PREJUIZO_EXERCICIO_CONTABIT) sem negar; sinal invertido (_saldo_sinal_invertido_contabit()) é "analítica com saldo negativo", com a natureza (grupo, invertida por redutora/descendente de redutora) usada só no texto. Premissa não confirmada: o Contabit imprimir saldo contrário à natureza com "-"; se imprimir de outro jeito, a regra só deixa de disparar, sem falso positivo.
Sem indicadores (decisão do usuário, por enquanto): os indicadores cadastrados usam códigos e rótulos do plano do Questor e sairiam errados. ContabilApuracao.tem_indicadores (False no Contabit) faz _contabil_dados_resumo() devolver indicadores_grupos vazio sem calcular, indicadores() devolver vazio, o relatório e o PDF do Resumo pularem a seção, e a aba "Dashboard" esconder os cards, a dica e o botão "Gerenciar Indicadores" (renderDashboardTab()). Para ligar indicadores no Contabit no futuro seria preciso mapear os códigos por leiaute nos componentes (1.10 Circulante, 2.10 PC, 2.40 PL, 1.20.30 Imobilizado, 1.20.30.20 Depreciação; Estoques ainda sem exemplo).
Motor de regras de auditoria (regras.py)
Cada regra_* é uma função pura: (ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado].
historico continua na assinatura de toda regra por uniformidade (gera_achados() chama todas do mesmo jeito), mas nenhuma das 9 regras atuais usa esse parâmetro. _contabil_monta_historico() (em views.py, limitado às 2 apurações anteriores) e o parâmetro busca_historico de pipeline.processa_apuracao() continuam existindo de propósito, como ponto de extensão já desenhado — não foram arrancados só porque nada os usa hoje.
As 9 regras
(Mais um 10º tipo de apontamento que não é regra, ver "conta/linha removida no reprocessamento" abaixo.)
balanceamento_ativo_passivo(alta) — soma do grupo Ativo (codigo="1") deve fechar exatamente com a do Passivo (codigo="2", que já vem negativo no relatório). Sem tolerância de centavos.debito_credito_divergente(alta) — soma de Débito das contas-raiz (codigosem ponto, ou seja só "1" e "2") deve bater exatamente com a soma de Crédito. Não é uma checagem trivial: como a DRE não tem colunas de débito/crédito próprias neste relatório (só um valor líquido por linha), a identidade só fecha porque a movimentação de Resultado transita pelas contas de Patrimônio Líquido do Passivo ("LUCROS/PREJUÍZOS DO EXERCÍCIO") — confirmado empiricamente contra o PDF de referência (débito total = crédito total = R$ 416.271.243,32 nas contas-raiz).saldo_negativo_caixa(alta) — conta comcodigocomeçando em1.01.01.001(grupo Caixa) esaldo_atual < 0.saldo_sinal_invertido(média) — conta analítica (tipo="A") do Ativo com saldo credor, ou do Passivo com saldo devedor. Duas exceções: conta redutora (descrição começando com"(-)", que é esperado ter o sinal oposto ao grupo) e toda conta descendente de uma redutora — ver abaixo.lucro_balancete_diverge_dre(alta) — o resultado do exercício (lucro ou prejuízo) precisa ser o mesmo no Balancete e na DRE, sem tolerância. LêCODIGO_LUCRO_PREJUIZO_EXERCICIO("2.04.13.002", código fixo da linha sintética "LUCROS/PREJUÍZOS DO EXERCÍCIO" dentro do PL; agrega "LUCROS DO EXERCÍCIO" ou "(-) PREJUÍZOS DO EXERCÍCIO" conforme o resultado), negado (convenção de Passivo/PL com sinal invertido) contralinhas_dre[-1].valor. Validado batendo exato contra os 2 balancetes reais antes de entrar em produção.conta_transitoria_com_saldo(média) — descrição contém "TRANSIT" comsaldo_atual != 0, exceto a palavra isolada "TRANSITO" (\bTRANSITO\b, "dinheiro em trânsito", conceito diferente). A exclusão é por palavra isolada e não por um trecho positivo mais longo como "TRANSITOR" porque, no PDF1751, a fonte corrompe o acento de "TRANSITÓRIA" num caractere não recuperável — "TRANSITOR" nunca casaria com essa conta, que tem saldo real.conta_deveria_zerar(média) —TRECHOS_CONTA_DEVERIA_ZERAR, lista curta e deliberadamente restrita: só "ADIANTAMENTOS DE SALÁRIOS", que o ITD confirma dever ficar zerada todo mês. Não inclui "Adiantamento de Férias"/"13º Salário", que legitimamente carregam saldo entre meses.descricao_generica(baixa) — descrição exatamente"DIVERSOS"com saldo relevante (o ITD cita esse caso: "o contador deverá realocar estes lançamentos a conta pertinente").variacao_atipica_dre(baixa) — usa a seção "Demonstração Mensal (Análise Vertical)" do próprio PDF (atual.linhas_analise_vertical), não o histórico do Portal. Compara os 2 meses mais recentes dessa tabela pelopercentualde cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menosVARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO(1 ponto percentual — piso para não disparar em saltos percentualmente grandes só porque a base já era perto de zero) e, quando o percentual anterior não é zero,VARIACAO_LIMIAR_PERCENTUAL(65% de variação relativa). Roda mesmo na 1ª apuração de uma empresa, desde que o PDF traga a seção. Título "Variação atípica na DRE - Análise Vertical" (TITULO_VARIACAO_ATIPICA_DRE, pedido do usuário; a migração0081renomeou os já gravados). Tratada de um jeito próprio, sem justificativa escrita: ver "Variações atípicas no fim da aba Análise Vertical" abaixo.
O 10º apontamento não é uma regra: conta/linha removida no reprocessamento
achados_itens_removidos(removidos) (mesmo módulo, fora de REGRAS) monta um AchadoDetectado de severidade média por conta/linha que existia na apuração e não veio no arquivo do reprocessamento. Fica fora da lista porque gera_achados() só enxerga a extração do PDF atual, e "sumiu" só é visível comparando com o que estava salvo, que é o que a sincronização faz — quem chama é reprocessar() em views.py, juntando o resultado aos achados das regras antes de _contabil_recria_achados(). A construção do achado mora aqui mesmo assim, junto dos outros textos/severidades.
O rótulo é "codigo descricao" no Balancete e o caminho na árvore na DRE/Análise Vertical (sem o ramo, o aviso não diria qual das linhas homônimas saiu). Rótulos iguais viram um apontamento só citando as duas tabelas, já que DRE e Análise Vertical são a mesma árvore e uma linha retirada do PDF some das duas. conta/linha_analise_vertical ficam nulos (o registro foi excluído), então o card não mostra "Ver na tabela" — e codigo_conta não é preenchido de propósito: _contabil_recria_achados() casa código contra as contas que sobraram, e um código repetido entre contas analíticas apontaria para a conta errada.
O aviso vale para o reprocessamento em que a remoção aconteceu. Achado é recriado do zero a cada reprocessamento e a linha já não está no banco, então reprocessar de novo com o mesmo arquivo não repete o aviso: naquele ponto não há mais nada sendo removido. É o mesmo espírito de
alterada_reprocessamento, que também marca a mudança daquela rodada.
Por que não existe uma regra de variação sobre o Balancete: a Análise Vertical do PDF só cobre linhas da DRE. A alternativa (comparar saldo de conta contra a apuração anterior, via historico) existiu e foi removida a pedido do usuário, junto de uma regra de razão Custos/Receita — a granularidade de variacao_atipica_dre sobre a Análise Vertical já cobre qualquer linha da DRE sem precisar de regra dedicada.
Descendente de conta redutora não dispara saldo_sinal_invertido
Se a conta "mãe" (sintética, em qualquer nível acima, não só o pai direto) começa com "(-)", o sinal "invertido" dos analíticos dentro dela é o comportamento esperado daquela natureza de conta, não uma inconsistência. Caso real: duas analíticas dentro de 2.04.01.003 "(-) CAPITALA INTEGRALIZAR" (sic, espaço grudado — a mesma classe de artefato descrita em "PDF de fonte atípica") herdam o sinal devedor esperado, mas não têm "(-)" na própria descrição.
_indices_descendentes_de_conta_redutora(contas) calcula, para a árvore inteira de uma vez, quais índices têm algum ancestral redutora, usando a pilha de níveis (codigo.count(".") + ordem de leitura do PDF) — não comparação de prefixo de código. O motivo é importante: o Questor reaproveita o mesmo código de classificação entre contas analíticas irmãs (ver também ContabilObservacao.chave_conta() abaixo), então string matching por código não é confiável para achar o pai; a pilha de níveis segue a profundidade real da árvore impressa, e funciona mesmo com códigos repetidos entre irmãos.
Validado de duas formas: árvore sintética reproduzindo o caso do usuário (conta "(-) LUCROS DISTRIBUÍDOS" com 2 sócios dentro), confirmando que o achado deixa de ser gerado e que seria gerado sem a correção; e rodando contra as 3 apurações reais em produção, com 32 contas identificadas como descendente de redutora (a maioria depreciação acumulada, 1.02.05.007.*), sem falso positivo aparente.
Formatação de valores nas mensagens
_moeda(valor: Decimal) -> str (local em regras.py, duplicado em vez de importado — este pacote é Python puro, sem tocar no ORM/app registry; mesmo padrão por arquivo de indicadores/recibo.py/custo_contratacao/pdf.py/templatetags/contabil_extras.py). Todas as mensagens de achado que interpolam um valor usam _moeda(), nunca f"R$ {valor}" direto (que usa str(Decimal(...)) e nunca tem separador de milhar).
Pendência conhecida: regra_variacao_atipica_dre ainda formata percentual com f"{valor:.2f}%" (ponto decimal, "12.34%"), inconsistente com o padrão BR. Mesmo tipo de ajuste, ainda não pedido.
Mensagem de achado é congelada no momento em que o achado é criado — um achado já persistido mantém o texto com que nasceu até a apuração ser reprocessada (o que recria todo achado do zero) ou até uma apuração nova da mesma empresa ser criada. Não existe migração de dados reformatando texto já gravado: parsear números dentro de frase livre por regex é arriscado, já que o mesmo texto tem números que não são valores (código de conta "1.01.01.001").
Models (portal_api/models.py)
Padrão cabeçalho → linhas de detalhe → apontamentos, mesma filosofia de IndicadorApuracao/IndicadorApuracaoColaborador.
Apuração e linhas
ContabilApuracao—codigo_empresa/nome_empresa/cnpj/competencia(extraídos do PDF, não informados no upload) +periodo_inicio/periodo_fim+arquivo+status(revisao/concluida).unique_togetheremcodigo_empresa+competencia. Mais:analise_vertical_meses(JSONField, ex.["mai/2026", "jun/2026", "jul/2026"], lista compartilhada por toda a apuração),fonte_pdf_atipica,leiaute(questor/contabit, detectado pelo parser;db_defaultno banco, não sódefaultdo Django, porque o banco é o de produção e um processo com código anterior ao campo não o enviaria) + a propriedadetem_indicadores,resumo_fechamento(texto rico do contador),indicadores_ocultoseindicadores_selecionados(JSONField, listas de chave de indicador).ContabilConta— uma linha do Balancete.conta_numero(numeração interna do Questor) ecodigo(classificação) são coisas diferentes, as duas extraídas.validado(BooleanField, checkbox informativo de "já conferi", sem gate em nada),alterada_reprocessamento+valor_anterior_reprocessamento.ContabilLinhaDre— uma linha da DRE, sem código de classificação. Mesmosvalidado/alterada_reprocessamento/valor_anterior_reprocessamento.ContabilLinhaAnaliseVertical— mesma árvore/descrição/nível da DRE, masvalores(JSONField) guarda um{"valor": "...", "percentual": "..."}por mês, gravado como texto, não float, para não perder precisão; alinhado por posição comContabilApuracao.analise_vertical_meses.valores_anterior_reprocessamentotem o mesmo formato (não existe um valor único aqui). Lista vazia quando o PDF não traz a seção — relatório antigo ou empresa sem essa seção habilitada no Questor; a aba correspondente some nesse caso.
As três têm os mesmos recursos por linha: observação (via ContabilObservacao), tri-state de "validado" e ocultar do relatório. E as três ordenam por ["ordem", "id"], não só por ordem: a árvore (o nível de cada linha em relação à anterior) e a chave natural da DRE/Análise Vertical dependem da ordem de leitura, então um empate de ordem não pode deixar o resultado à mercê do plano de execução do Postgres.
Apontamentos de auditoria
ContabilAchado— nasce automático emcreate(), muda destatus(pendente/tratado/ignorado) viaContabilAchadoViewSet, sempre comobservacao_contadorobrigatória ao sair de pendente, excetovariacao_atipica_dre, validada sem texto pela actionvalidar()(o texto dela é observação da linha, ver "Variações atípicas no fim da aba Análise Vertical"). Um reprocessamento apaga e recria todos os achados do zero (ver "Reprocessar" abaixo). Dois campos de alvo, mutuamente exclusivos e os dois opcionais:conta(FK paraContabilConta) elinha_analise_vertical(FK paraContabilLinhaAnaliseVertical). As duas regras gerais (balanceamento e débito/crédito) não têm alvo nenhum.
"Achado" nunca aparece em texto visível ao usuário — pedido explícito. Na UI a aba se chama "Observações" e os textos falam em "observação"/"apontamento".
achado/ContabilAchado/achados_com_observacaocontinuam normais como nome de model/variável/classe CSS. Ver[[feedback_nunca_achado_em_texto_visivel]]na memória.
Observações (ContabilObservacao)
Uma observação não é campo da linha: é um registro próprio, escopado por codigo_empresa + alvo_tipo (conta/dre/analise_vertical) + alvo_chave, que atravessa competências. Ver a seção "Observações" abaixo para vigência/imutabilidade. Campos: alvo_rotulo (descrição congelada no momento em que foi escrita, para exibir se a conta sumir do plano), apuracao_origem (SET_NULL) + competencia_origem (cópia, para a vigência continuar resolvendo se a apuração for excluída), texto, mostrar_ao_cliente, criado_por/criado_em e o trio encerrada_em_competencia/encerrada_por/encerrada_em.
ContabilObservacaoEdicao— um registro porPATCHque mudatextode fato. Nunca pormostrar_ao_cliente/encerrar/reativar, e nunca quando o texto enviado é igual ao já salvo.ContabilApuracaoReprocessamento— um registro por reprocessamento bem-sucedido (reprocessado_porSET_NULL,reprocessado_emauto_now_add). Criado dentro da transação do reprocessamento, então uma tentativa que falha no meio não deixa registro órfão.
Indicadores
IndicadorContabilDefinicao—chave(SlugFieldúnico, sempre derivada donomena criação, nunca aceita do cliente; imutável depois, já que pode estar referenciada emindicadores_ocultosou na fórmula de outro indicador),nome,descricao,formula(a expressão de cálculo),formula_exibicao(texto livre, sem validação nenhuma, só para exibição ao cliente),formato(moeda/percentual/indice),icone(choices, 11 opções, default"barras"),grupo(livre, só agrupa visualmente),padrao(BooleanField, defaultTrue),criado_por/criado_em/atualizado_em.IndicadorContabilComponente(related_name="componentes") — uma peça da fórmula.chaveé um identificador Python válido (^[a-z][a-z0-9_]*$,RegexValidator) e não umSlugFieldcomum, porque vira nome de variável dentro da árvoreastdo avaliador (diferente dachavedaDefinicao, que pode ter hífen).tipoécontas/linha_dre/variacao_conta/indicador/resultado_liquido, e só um campo de referência é preenchido conforme o tipo:contas_codigos,linhas_dre_descricoes,indicador_referenciado.
Guardado por código/descrição, nunca por FK a uma linha de uma apuração específica — a definição é genérica, reaplicada em qualquer apuração/empresa. Funciona quando a empresa usa o mesmo plano de contas e sai errado silenciosamente se não (mesmo risco já aceito pelos códigos fixos das regras).
Criação de uma apuração (ContabilApuracaoViewSet.create())
Diferente de IndicadorApuracaoViewSet/ImportacaoPlanoSaudeViewSet (onde a chave natural vem do formulário), aqui codigo_empresa/competencia só são conhecidos depois de extrair o PDF. A ordem importa:
- Lê o arquivo inteiro para memória (
arquivo.read()) — nada em disco ainda. dashboard_contabil_pipeline.processa_apuracao(io.BytesIO(conteudo), _contabil_monta_historico)— extrai cabeçalho/contas/DRE/Análise Vertical e, com a empresa/competência em mãos, chama_contabil_monta_historico()(função injetada, consulta o ORM) e roda as regras.ContabilExtracaoInvalidaErrorvira 400.- Confere se já existe apuração para essa empresa+competência (
.exists()) → 400 com mensagem específica, antes de qualquer escrita (evita depender doIntegrityErrorcru, que devolveria 500). - Só então, dentro de
transaction.atomic(), cria aContabilApuracao+bulk_createde contas / linhas DRE / linhas de Análise Vertical / achados. O PDF não é gravado (rodada 156 do CHANGELOG desta pasta, pedido do usuário: nada o lia depois da extração, só ocupava espaço emmedia/); o limite de 15MB fica no serializer (validar_tamanho_arquivo_contabil).
pipeline.processa_apuracao(origem, busca_historico) recebe busca_historico como função (não uma lista pronta) exatamente por essa dependência: a chave de busca só existe depois da extração.
Os achados de variacao_atipica_dre são ligados à linha certa por ordem (a posição de leitura no PDF, já existente em LinhaAnaliseVerticalExtraida/ContabilLinhaAnaliseVertical): monta-se av_por_ordem = {linha.ordem: linha ...} depois de persistir as linhas, e resolve-se achado.ordem_linha_analise_vertical → ContabilLinhaAnaliseVertical. Não por descrição (ambígua entre centros de custo) nem por id (que não existe no momento em que regras.py roda, sendo Python puro sem ORM).
Reprocessar
POST /api/contabil-apuracoes/{id}/reprocessar/ (multipart arquivo), bloqueado por _contabil_garante_em_revisao() — não existe reprocessar apuração concluída. Serve para corrigir uma análise feita com o PDF errado/incompleto sem perder o trabalho já registrado. O PDF novo precisa ser da mesma empresa+competência (senão 400 — trocar de empresa é uma análise nova).
Roda o mesmo processa_apuracao() de create() (o PDF novo também não é gravado) e delega a resincronização para funções puras com duas estratégias opostas:
_contabil_sincroniza_contas()/_linhas_dre()/_linhas_analise_vertical()— atualização no lugar (mesmoid), nunca delete+recria. Casam cada linha extraída contra a existente por chave natural:(codigo, descricao)no Balancete, caminho na árvore + nível na DRE/Análise Vertical (verchaves.pye "Observações" abaixo). Casada: atualiza os campos brutos no mesmo registro (.save()); se algum campo relevante mudou, forçavalidado=Falseealterada_reprocessamento=True, guardando o valor de antes emvalor_anterior_reprocessamento/valores_anterior_reprocessamento(sempre lido antes de sobrescrever); senão preserva tudo, inclusive limpando esse campo. Sem match na extração nova: cria, com os defaults de sempre. Sobra no fim:.delete().
O mapa de linhas já salvas é uma fila por chave (
_contabil_agrupa_por_chave()), nunca um{chave: linha}. Com dict, duas linhas de mesma chave viravam uma só: a perdedora ficava fora do mapa, então não era atualizada, e fora do laço de exclusão (que varre o mapa, não a tabela), então não era excluída — sobrevivia a todo reprocessamento com o valor congelado da primeira importação e sem nem o badge dealterada_reprocessamento. Foi assim que uma linha retirada do PDF continuou aparecendo na tela, com valor maior que o do próprio grupo pai. A chave por caminho resolve o caso comum (rótulo repetido em ramos diferentes), a fila cobre o resto (irmãs genuinamente idênticas no mesmo ramo): cada ocorrência do PDF novo consome uma da fila na ordem de leitura, e o que sobra é excluído, então a contagem de linhas salvas bate sempre com a do PDF. Os três devolvem(origem, rótulo)de tudo que excluíram, ereprocessar()transforma isso em apontamento na aba Observações (regras.achados_itens_removidos(), ver acima) — a exclusão em si é silenciosa, e uma conta sumir entre um arquivo e outro é exatamente o tipo de mudança que o contador precisa conferir. Pedido explícito do usuário.
_contabil_recria_achados()— delete+recria total, o mesmobulk_createdecreate(). Todo achado nascependente, mesmo que a mesma(regra, conta)já estivesse tratada com justificativa escrita — a justificativa antiga some junto.
Por que estratégias opostas: conta/linha é dado extraído que o contador anota — o valor de hoje precisa ser atualizado, mas a anotação de ontem sobre a mesma conta continua valendo. Achado é um apontamento derivado, recalculado inteiro a cada rodada das regras: não existe "achado que não mudou", ele dispara com os dados de agora ou não dispara. Manter um achado "tratado" que já não dispara equivale a mostrar uma inconsistência que não existe mais, contrariando o propósito de sinalizar o que precisa de atenção. Decisão explícita do usuário, revertendo a escolha original de preservar tratativas.
Isso não quebra FK nenhuma: não há achado preservado através do delete, então ContabilAchado.conta é sempre resolvida fresca contra o mapa de contas já sincronizadas (roda antes, na mesma transação). ContabilObservacao nunca teve relação com ContabilAchado — vive em model separado, casada por chave natural, e _contabil_recria_achados() nem a toca. A imunidade da observação ao reprocessamento é de graça: ela não mora na linha.
Marcar como validada de novo NÃO limpa o alerta de alteração (pedido explícito): o badge muda de cor conforme validado — --danger enquanto pendente, verde depois de validado — para dar para ver quais itens foram reprocessados e revalidados. alterada_reprocessamento só é limpo de verdade num próximo reprocessamento em que aquela conta/linha não mudar.
Observações: histórico por empresa+conta
Uma observação registrada num mês reaparece na análise dos meses seguintes, assinada e datada, bloqueada para edição por ser registro histórico. Três decisões de escopo, confirmadas antes de implementar:
- Toda observação propaga por padrão. Não existe "fixar"; existe o inverso, encerrar explicitamente. Evita histórico que só existe quando alguém lembra de marcar.
- O histórico cobre Balancete/D.R.E./Análise Vertical. A justificativa de tratativa de um achado (
ContabilAchado.observacao_contador) continua presa à apuração — e, como o achado é recriado a cada reprocessamento, ela nem sobrevive a isso. SóContabilObservacaoatravessa competências. mostrar_ao_clienteé sempre alternável, inclusive numa observação já travada. O bloqueio protege texto, autor e data; mostrar ou não ao cliente é decisão editorial de cada relatório, e uma marcação errada precisa ser corrigível sem reescrever o histórico.
Chave natural, nunca FK para a linha: "codigo|descricao" no Balancete, "caminho na árvore|nivel" na DRE/Análise Vertical (ex.: "(-) DESPESAS OPERACIONAIS > DESPESAS DE VENDAS > DESPESAS COM PESSOAL|2"). A regra mora em chaves.py (Python puro) e é consumida por chave_conta()/chave_linha() no model, _contabil_chave_alvo() na view e dcChaveObsConta()/dcChaveObsLinha() no JS — mesmo formato nos três. São exatamente as chaves que a sincronização do reprocessamento usa.
O caminho na chave da DRE não é enfeite. O mesmo rótulo aparece em ramos diferentes no mesmo nível ("DESPESAS COM PESSOAL" sob "DESPESAS DE VENDAS" e sob "DESPESAS ADMINISTRATIVAS"), e com a chave
(descricao, nivel)a observação escrita numa vazava para a outra, além de o reprocessamento perder uma das duas linhas (ver "Reprocessar" acima). Mesma classe de problema que a descrição já resolvera no Balancete.Consequência prática: a chave de uma linha não é calculável a partir da linha isolada, só percorrendo a árvore. Por isso
_contabil_chave_alvo()recebe a apuração inteira,_contabil_arvore_contexto()recebe uma lista de chaves alinhada por posição (não umachave_fn), e o JS calcula as chaves por tabela emdcAplicaChavesObs()(no começo derenderDre()/renderAnaliseVertical(), não só ao abrir a apuração: marcar uma linha como validada troca o objeto pelo retorno da API). Ao mexer em qualquer um dos lados, os dois precisam continuar produzindo a mesma string.
A descrição faz parte da chave do Balancete, e isso não é redundância. O Questor reaproveita o mesmo código de classificação para várias contas analíticas de mesma natureza — confirmado pelo usuário com 6 bancos diferentes (Banco do Brasil, Inter, Itaú, Mercado Pago, PagSeguro, Sicredi) sob o mesmo código de "Depósitos Bancários à Vista". Com a chave só por
codigo, uma observação escrita num banco aparecia em todos os outros. Trade-off aceito: uma conta renomeada com o mesmo código vira uma conta "nova" no reprocessamento (a antiga é excluída, outra é criada) — o mesmo trade-off que a DRE já aceitava.
Vigência (vigentes_para()/vigente_em()): a observação aparece em toda apuração da mesma empresa com competencia_origem <= C e (encerrada_em_competencia nulo ou >= C). Daí saem os três caminhos: manter é não fazer nada; encerrar grava a competência aberta (a observação continua visível nela e some da seguinte em diante — o histórico nunca é reescrito); incluir uma nova cria outro registro, então uma conta tem uma thread, não um texto único.
Imutabilidade e exclusão:
textosó é aceito enquanto a apuração de origem estiver "Em revisão" (_garante_texto_editavel()); depois disso a edição é recusada com 400, pedindo para registrar uma nova ou encerrar a existente.- Uma edição de texto que passa deixa rastro em
ContabilObservacaoEdicao, exibida por um botão de relógio na thread — para editar não virar uma forma indireta de apagar uma observação importante reescrevendo por cima. DELETEé permitido, mas com duas travas somadas: a mesma regra de "só em revisão" ecriado_por_id == request.user.id(senãoPermissionDenied). Mostrar ou esconder o botão no frontend é só UX; o servidor confere as duas de novo.- Excluir e encerrar convivem de propósito: excluir é definitivo e só de quem criou; encerrar é reversível e qualquer um do time pode usar.
Os ícones de "encerrar" e "excluir" não podem ser o mesmo desenho. Encerrar usa um ícone de arquivo (caixa com uma linha); a lixeira (
PID_DC_ICON_LIXEIRA) é só da exclusão de verdade. Os dois aparecem lado a lado na mesma linha de ações, e uma é reversível e a outra não.
Indicadores
Todo indicador é um IndicadorContabilDefinicao no banco — os 11 "de sistema" (ROA, ROE, Kanitz, EBIT, EBITDA, as três liquidezes, composição/grau de endividamento, IPL) também. Não são caso especial em lugar nenhum do código: CRUD, cálculo e exibição passam pelo mesmo caminho de qualquer indicador personalizado. Qualquer contador com acesso à ferramenta pode criar/editar/excluir — não é tela administrativa restrita ao perfil "Inovação" (decisão deliberada: quem cria os indicadores é o próprio contador usando a ferramenta).
Motor de fórmula (formula.py)
avalia_formula(expressao, valores) faz ast.parse(expressao, mode="eval") e anda pela árvore com um allowlist restrito: só BinOp (+ - * /), UnaryOp (+ -), número literal e Name (resolvido contra o dict valores). Qualquer outro nó (chamada de função, atributo, import, comparação) levanta FormulaInvalidaError antes de qualquer coisa ser executada — nunca eval()/compile() sobre texto digitado pelo contador.
Se qualquer nome referenciado valer None, ou a fórmula dividir por zero, o resultado inteiro é None. "Indisponível" nunca vira 0 — disciplina seguida em todo o pacote. valida_formula(expressao, chaves_disponiveis) roda a mesma árvore com valores fictícios só para validar sintaxe/nomes na hora de salvar.
Resolução contra uma apuração (views.py)
_ContabilDadosIndicadores(dataclass) +_contabil_coleta_dados_indicadores(apuracao)juntamcontas_atuais/dre_atual/resultado_liquido/historico_completoe derivamcontas_anteriores(só a apuração anterior imediata). Reaproveitado por todos os caminhos de cálculo, evitando duas idas ao banco pelos mesmos dados._contabil_resolve_componente_personalizado()resolve um componente:contas/variacao_contasomam em valor absoluto (Passivo/PL vêm negativos no relatório);linha_dresoma com o sinal já impresso (a DRE não segue essa convenção);variacao_contaésoma_atual − soma_anterior,Nonesem apuração anterior;indicadorfazvalores.get(chave);resultado_liquidoresolve sempre paradados.resultado_liquido._contabil_calcula_indicadores_personalizados()resolve todas as definições. Iterativo, não ordenação topológica de verdade: a cada rodada calcula todo indicador cujos componentestipo="indicador"já têm valor, repetindo até não sobrar progresso. Cobre encadeamento sem ordenar dependências explicitamente. Um indicador cuja dependência nunca resolve (referência quebrada ou ciclo entre dois personalizados) ficaNonepara sempre, sem lançar erro — uma fórmula mal configurada não pode derrubar o relatório inteiro.
Por que existe o tipo
resultado_liquidoem vez de um componentelinha_dreapontando para a última linha: a última linha da DRE muda de rótulo conforme o sinal do resultado — uma empresa com prejuízo termina em "(=) PREJUÍZO LÍQUIDO DO EXERCÍCIO", com lucro terminaria em "(=) LUCRO LÍQUIDO DO EXERCÍCIO". Umlinha_dre(que casa por texto exato) quebraria assim que o resultado de uma empresa virasse de sinal entre uma competência e outra.resultado_liquidoresolve por posição, não por texto.
Padrão vs. não padrão
padrao=True (default) faz o indicador aparecer em toda apuração. padrao=False deixa o indicador salvo e editável, mas ele só aparece numa apuração em que sua chave esteja em ContabilApuracao.indicadores_selecionados. Um indicador não padrão não selecionado não aparece em lugar nenhum daquela apuração (nem relatório, nem aba "Dashboard"), mas continua na listagem completa do hub "Gerenciar Indicadores", que nunca é filtrada por apuração.
indicadores_ocultos é ortogonal: esconde do relatório do cliente um indicador que está aparecendo.
Exclusão de definição
perform_destroy() bloqueia (400) se outra definição referencia esta pela fórmula, listando os nomes dependentes — para não deixar fórmula alheia quebrada em silêncio. Depois de excluir, limpa a chave de toda ContabilApuracao.indicadores_selecionados/indicadores_ocultos que a referenciava: sem essa limpeza a chave fica órfã e, como os dois serializers validam a lista inteira a cada alternância de checkbox, o usuário fica travado sem conseguir alternar nenhum indicador naquela apuração — não só o excluído. As duas validações também descartam chave inexistente em silêncio, como rede de segurança (é só estado de exibição, não dado auditado).
Fórmula de cálculo vs. fórmula exibida
formula é a expressão validada, usada só para calcular, e mostra as chaves internas dos componentes (resultado_liquido - despesas_financeiras). formula_exibicao é texto livre, nunca passa por avalia_formula()/ast, e é o que o cliente vê. No modal, os campos são rotulados "Fórmula (cálculo interno)" e "Fórmula (como aparece ao cliente)". O servidor resolve o fallback: definicao.formula_exibicao.strip() or definicao.formula — indicador sem a preferência preenchida mostra a fórmula técnica, nunca fica sem fórmula nenhuma.
Ícones: duas cópias mantidas à mão
O miolo <svg> de cada um dos 11 ícones existe em _CONTABIL_ICONES_SVG (views.py, montado em card["icone_svg"] via mark_safe()) e em PID_DC_INDICADOR_ICONES (dashboard-contabil.js, desenha a grade do seletor). Duplicação proposital: o relatório é HTML servido pelo Django (sem acesso ao JS do app) e o modal de cadastro é JS sobre uma TemplateView sem contexto de servidor — não há fonte única sem inventar mais uma ida ao backend. Editar ou adicionar um ícone exige mexer nos dois lugares.
Riscos e calibragens herdadas
- Os códigos de classificação são fixos e calibrados contra um balancete de referência. Ativo
"1", Ativo Circulante"1.01", Estoques"1.01.08", Imobilizado"1.02.05", Depreciação Acumulada"1.02.05.007", Passivo"2", Passivo Circulante"2.01", PL"2.04". Se um cliente usar numeração de plano de contas diferente, os indicadores dele saem errados silenciosamente. O Passivo Não Circulante não tem código calibrado (não aparece no balancete de referência) e é calculado por eliminação (Passivo Total − Passivo Circulante − PL), sempre exato pela identidade contábil. - Kanitz não foi validado contra o BI antigo que esta ferramenta substitui. Usa a fórmula-livro-texto padrão (
0,05×ROE + 1,65×LiqGeral + 3,55×LiqSeca − 1,06×LiqCorrente − 0,33×GrauEndiv), mas um valor visto numa captura do usuário ("11,31") está fora da faixa clássica (−7 a +7), sugerindo escala diferente no BI antigo. O relatório marca o card como estimativa. - Liquidez Geral é aproximada, sem exemplo real para validar: trata o Realizável a Longo Prazo como 0, porque o Ativo Não Circulante (
"1.02") mistura Investimentos/Imobilizado com um eventual RLP sem separar. - EBIT é calculado "de baixo para cima" (
resultado_liquido − despesas_receitas_financeiras), não pela definição-livro-texto "de cima para baixo" (Receita Líquida − Custos − Despesas Operacionais). Aformula_exibicaodocumenta o que o código faz, não a definição conceitual. Os dois caminhos tendem a convergir num DRE bem formado, mas não foram provados equivalentes. A linha de despesas/receitas financeiras é casada por substring (LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRASemparser.py), não validada contra PDF real. - EBITDA e Depreciação do mês ficam indisponíveis na primeira apuração de uma empresa (dependem da variação do saldo de depreciação acumulada contra a apuração anterior).
- Grau de Endividamento e IPL não multiplicam por 100 na fórmula — o texto original do glossário do usuário dizia "÷ (PL × 100)", que é só a forma de dizer "o resultado vira %". O cálculo é
A ÷ B, exibido pelo filtropercentual.
dashboard_contabil/indicadores.pyestá sem consumidor em produção e é mantido de propósito.calcula_indicadores()/IndicadoresFinanceiros/_saldo()/_divide()/CODIGO_*continuam lá mesmo sem chamador (a wrapper_contabil_calcula_indicadores()emviews.pyexiste e não é mais invocada). É a única exceção neste projeto à convenção de apagar código sem uso, justificada pelo risco de indicador financeiro: se um valor um dia parecer suspeito, dá para recalcular pelo caminho antigo e comparar. Antes da migração para o banco, um dry-run comparou os 11 indicadores calculados pelos dois caminhos contra a mesma apuração real e bateram exatos até a vigésima casa decimal, incluindo oNonedo EBITDA. Revisar se ainda vale manter depois de a migração provar estabilidade por um tempo.
API
Todos os endpoints usam PermissaoApp("relatorios", "dashboard-contabil"). Nenhum deles aparece na tabela de API do CLAUDE.md da raiz de propósito — endpoint de aplicação mora na doc da aplicação.
| Endpoint | Método | Uso |
|---|---|---|
/api/contabil-apuracoes/ |
GET/POST | histórico + criação (multipart, um PDF; empresa/competência vêm do arquivo) |
/api/contabil-apuracoes/{id}/ |
GET/DELETE | detalhe (contas + linhas DRE + Análise Vertical + achados de uma vez) / excluir. DELETE é bloqueado (400) em apuração concluída |
/api/contabil-apuracoes/{id}/reprocessar/ |
POST | multipart, PDF novo da mesma empresa+competência |
/api/contabil-apuracoes/{id}/concluir/ |
POST | fecha a análise (trava edições) |
/api/contabil-apuracoes/{id}/observacoes/ |
GET | recorte de vigência das ContabilObservacao, já com historica/encerrada calculados contra a competência desta apuração |
/api/contabil-apuracoes/{id}/dashboard/ |
GET | o relatório HTML do cliente (ver por que é GET, abaixo) |
/api/contabil-apuracoes/{id}/indicadores/ |
GET | {indicadores, indicadores_ocultos, metadados} para a aba "Dashboard" |
/api/contabil-apuracoes/{id}/indicadores-ocultos/ |
POST | substitui a lista inteira de chaves ocultas |
/api/contabil-apuracoes/{id}/indicadores-selecionados/ |
POST | idem, para indicador não padrão ativado nesta apuração |
/api/contabil-apuracoes/{id}/resumo-fechamento/ |
POST | texto rico do contador (sanitizado por nh3) |
/api/contabil-apuracoes/{id}/pre-visualizar-indicador/ |
POST | calcula um indicador ainda não salvo contra esta apuração, sem persistir nada |
/api/contabil-apuracoes/{id}/exportar-xlsx/?parte=balancete|dre |
GET | planilha da tabela |
/api/contabil-apuracoes/{id}/resumo-pdf/ |
GET | PDF avulso da aba Resumo |
/api/contabil-contas/{id}/ |
GET/PATCH | validado (e demais campos graváveis) de uma conta |
/api/contabil-linhas-dre/{id}/ |
GET/PATCH | idem, linha da DRE |
/api/contabil-linhas-analise-vertical/{id}/ |
GET/PATCH | idem, linha da Análise Vertical |
/api/contabil-observacoes/ |
POST | cria (recebe apuracao + alvo_tipo + alvo_id; empresa, competência, chave e rótulo são derivados no servidor — alvo de outra apuração é 400) |
/api/contabil-observacoes/{id}/ |
PATCH/DELETE | texto e/ou mostrar_ao_cliente, cada um com sua regra / exclusão restrita ao autor e à revisão |
/api/contabil-observacoes/{id}/encerrar/, /reativar/ |
POST | recebem a apuração aberta no corpo, que define a competência de corte |
/api/contabil-achados/{id}/ |
GET/PATCH | tratar/ignorar (exige observacao_contador não vazia) |
/api/contabil-achados/{id}/validar/ |
POST | {validado: bool}, só variacao_atipica_dre (400 nas outras regras): true trata sem observacao_contador, false volta a pendente; bloqueado em apuração concluída |
/api/contabil-achados/{id}/alternar-oculto/ |
POST | esconder a observação do achado no relatório, independente da tratativa (um achado pode continuar pendente e ter a observação escondida) |
/api/contabil-indicadores-definicoes/, /{id}/ |
GET/POST/PATCH/DELETE | CRUD de indicador (corpo sempre o indicador inteiro, inclusive em PATCH) |
Notas de desenho:
ContabilApuracaoViewSetnão tem PATCH genérico (http_method_namesexclui"patch"de propósito). Toda edição de campo da apuração passa por uma@actiondedicada que substitui aquele campo de uma vez, sempre guardada por_contabil_garante_em_revisao().ContabilObservacaoViewSetnão temlist/retrievede propósito: a leitura é sempre pelo recorte de vigência de uma apuração.indicadores()devolvedataclasses.asdict(...)puro, sem passar porSerializer— osDecimal/Nonejá chegam certos no JSON porque oJSONRendererdo DRF aplica seu encoder recursivamente em qualquer estrutura de resposta, não só em campo deSerializer.get_queryset()fazprefetch_relateddeachados__conta,achados__linha_analise_vertical,reprocessamentos__reprocessado_por(este só emlist) eedicoes__editado_pornas observações.total_achados_pendentesainda gera N+1 na listagem — conhecido, não corrigido.
Frontend — tela de revisão (dashboard-contabil.html / .js / .css)
Três sub-views no padrão de indicador-desempenho.html: #dc-list-view (histórico + "Nova Análise") / #dc-form-view (upload de um PDF, sem campo de competência) / #dc-review-view (abas via .pa-tabs/.pa-tab-panel de perfis-acesso.css).
Abas da revisão: Observações (os achados) / Balancete / D.R.E. / Análise Vertical (só quando analise_vertical_meses não está vazio) / Dashboard.
Se a apuração aberta não tem Análise Vertical mas essa aba estava ativa (o contador vinha de outra apuração),
renderRevisao()força a volta para "Observações" — senão sobraria um painel visível com o botão de aba escondido.
As três árvores
Balancete, D.R.E. e Análise Vertical usam o mesmo algoritmo de árvore recolhível. O Balancete deriva o nível do código de classificação (dcContaNivel(), conta segmentos separados por .); as outras duas já recebem nivel pronto do backend. "Tem filhos" é sempre "a próxima linha tem nível maior". As funções genéricas (dcColapsoPadrao/dcUltimaLevaVisivel/dcFilhosDiretos/dcDescendentes/dcEstadoValidacaoGrupo/dcValidadoInfo) recebem itens/nivelFn como parâmetro e servem as três sem alteração. Estado de colapso é independente por aba (dcContasColapsadas/dcDreColapsadas/dcAvColapsadas).
O cabeçalho da Análise Vertical é montado em JS (renderAnaliseVerticalHead()) porque o número de colunas varia com os meses; nas outras duas é HTML fixo. Os listeners são delegados no <thead>, então sobrevivem ao innerHTML ser refeito.
As tabelas nascem com tudo expandido. renderRevisao() reinicia os três Set() de colapso vazios. A visão compacta virou ação sob demanda (ver o botão abaixo).
dcColapsoPadrao(itens, nivelFn) é a visão compacta: popula o Set com todo item que tem filhos e está no nível PID_DC_NIVEL_ABERTO_PADRAO (2) ou além. Não é padrão de abertura aqui, mas continua sendo o padrão do relatório do cliente (que é server-side). PID_DC_NIVEL_ABERTO_PADRAO (JS) e _CONTABIL_NIVEL_ABERTO_PADRAO (views.py) são a mesma constante conceitual duplicada nos dois lados — mudar o limiar exige ajustar os dois.
Cabeçalho: dois botões por tabela, em pontas opostas
.dc-recolher-grupos-btn(data-dc-recolher-grupos="balancete|dre|analise-vertical"), na primeira coluna, dentro de.dc-th-linha.dc-th-linha--inicio(modificador que só troca ojustify-content, já que este botão vem antes do rótulo). É um toggle de duas faces: mostra "−" e aplicadcColapsoPadrao()enquanto nada está recolhido; mostra "+" e zera oSetassim que existe qualquer grupo recolhido. A face sai dedcAtualizaBotaoArvore(), chamada no fim de cadarender*(), e é derivada decolapsadas.size, não de um flag próprio — então continua correta quando o contador recolhe uma linha pelo toggle dela, sem passar pelo cabeçalho..dc-reset-formatacao-btn("Restaurar formatação"), colado à direita na coluna "Observação". Faz o mesmo que a face "+". Ficou redundante e foi mantido de propósito, por ser a "borracha" que o usuário já conhece de outras telas do Portal.
Ponto não-óbvio: os dois zeram dc*Expandidos em vez de populá-lo com todos os grupos. Com o conjunto vazio, dcDestaqueGrupos() cai na leva padrão, que sem nada recolhido marca exatamente as folhas. Popular dc*Expandidos com todos os grupos destacaria quase toda linha da tabela, e com tudo destacado nada se destaca.
Destaque de linha
Duas famílias de Set() por árvore (as 6 resetadas em renderRevisao() e no botão de restaurar): dc*Expandidos guarda os grupos que o contador abriu e continuam abertos; dc*Destaque guarda o resultado derivado, sempre recalculado por dcDestaqueGrupos(itens, nivelFn, colapsadas, expandidos):
expandidosvazio → vale a leva padrão,dcUltimaLevaVisivel(itens, nivelFn, colapsadas).expandidoscom algo → o destaque é só a união dedcFilhosDiretos()de cada grupo aberto, descartando a leva padrão por completo.
O handler de clique guarda estavaColapsada antes de mutar o Set de colapso; expandir adiciona o id a expandidos, recolher remove o id e todo descendente dele (senão o destaque apontaria para linhas agora invisíveis e a árvore nunca voltaria ao estado padrão). Puramente visual, sem persistência.
Por que a leva padrão é descartada quando há grupo aberto, e não somada. Somar os filhos revelados ao conjunto já existente parece o comportamento natural e não funciona: a leva padrão já marca praticamente toda linha de nível ≥ 2, então somar deixa a tabela inteira destacada. Confirmado simulando o algoritmo em Python contra contas reais. A regra correta é acumular entre grupos abertos (abrir um segundo grupo mantém o destaque do primeiro) mas ignorar a base padrão enquanto houver qualquer grupo aberto.
dcUltimaLevaVisivel() marca folha genuína, não só grupo colapsado. Reconstrói a lista de linhas realmente visíveis dado o colapso e marca toda linha cuja próxima linha visível não seja mais profunda que ela. Isso cobre os dois casos com uma regra só: nada foi revelado abaixo dela, seja porque está colapsada ou porque é uma folha sem filho nenhum. Um critério baseado só no Set de colapso deixa de fora folhas de verdade (como (-) SIMPLES NACIONAL na DRE), que visualmente estão no mesmo nível de um grupo colapsado vizinho.
Cores (dashboard-contabil.css): --dc-destaque-bg é o tom mais escuro (--bg-canvas) e --dc-row-tint-bg o mais claro (--bg-surface-raised) no tema escuro. --dc-row-tint-bg não pode ser --card-bg-hover, senão o hover das linhas não destacadas fica sem efeito visível. O tema claro usa a inversão oposta (não-destaque colorido, destaque em branco), também por pedido do usuário.
Checkbox "validado" com tri-state
Botão de check ao lado do de observação (.dc-conta-validado-btn, cor --teal quando marcado — deliberadamente não --accent, que já significa "observação preenchida" e é a cor de tema escolhida pelo usuário). Puramente informativo: não bloqueia conclusão, não afeta achado nem relatório.
Uma folha é um toggle simples. Uma sintética tem 3 estados, calculados a cada render a partir dos descendentes (todos, não só os diretos) via dcEstadoValidacaoGrupo():
"nenhum"— nem ela nem nenhum descendente está validado."parcial"(--gold) — qualquer combinação intermediária, inclusive só a própria sintética marcada."completo"(--teal) — todo descendente FOLHA está validado, checado primeiro.
O "completo" usa só
descendentesFolhas, nãodescendentescompleto. Numa árvore de 3+ níveis (mãe → filha → netos), validar os netos direto sem clicar na "filha" intermediária nunca marca o campovalidadodela no banco — só o estado visual dela é "completo", calculado por render. Checar tododescendentesna "mãe" fazia o grupo nunca fechar mesmo com todo neto validado.descendentes(todos) continua valendo para o "parcial": uma sintética marcada sozinha ainda deve sinalizar "em andamento" num ancestral.
Ciclo de 3 cliques numa sintética (dcClicarValidadoConta()/Linha()/Av()), recalculando o estado a cada clique:
"nenhum"→ PATCH só na própria sintética → vira"parcial"."parcial"→pidConfirm("Deseja validar todas as contas deste grupo?")→ PATCH em lote de todo descendente ainda não validado →"completo". Cancelado, nada muda."completo"→ PATCH em lote desmarcando tudo, sem perguntar.
Cada PATCH é individual (não existe action de lote); o "lote" é Promise.all client-side, aceitável porque um grupo real tem no máximo algumas dezenas de contas. Todo caminho termina re-renderizando a tabela inteira — necessário porque o estado de uma sintética ancestral também pode ter mudado de cor. Efeito colateral aceito: um editor de observação aberto em outra linha perde texto ainda não salvo nesse recálculo.
Observações: thread inline por linha
As observações são carregadas à parte da apuração (dcCarregarObservacoes(), ao abrir/criar uma análise) e indexadas por chave natural (dcObsIndice), porque não pertencem ao payload da apuração.
Clicar no ícone de observação abre uma <tr class="dc-obs-edit-row"> extra logo abaixo da própria linha, dentro da mesma tabela — não um modal. Motivo: um clique acidental fora de um popup não deve descartar texto em digitação, e ver a conta ao lado da observação ajuda a não perder o contexto. O painel é uma thread (dcObsPainelHtml()/dcObsItemHtml()): histórico em cima (autor, data, competência de origem, selos "Histórico"/"Encerrada"/"Editada"/"Aparece ao cliente"/"Interna" e as ações de olho, ver edições, editar, encerrar/reativar, excluir), campo de observação nova embaixo.
Um handler único (dcTrataCliqueObservacao()) atende as 3 tabelas e as 4 listas de resumo — a mesma observação pode estar visível em mais de um lugar ao mesmo tempo, então cada mutação refaz o fetch e re-renderiza tudo (dcRenderObservacoesTudo()).
O botão da coluna mostra sempre só o ícone, nunca o texto (PID_DC_OBSERVACAO_ICONE, botão circular de 26px), com um contador (.dc-obs-contador, já que uma conta pode ter várias). A cor é o sinal:
.dc-conta-observacao-btn--preenchida(--accent) — esta linha tem observação própria..dc-conta-observacao-btn--descendente(--gold) — a linha é sintética, não tem observação própria, mas algum descendente tem. Serve para o contador ver que há observação dentro de um grupo recolhido sem precisar expandir. Clicar continua abrindo o painel da própria linha (que nasce vazio), é só sinal visual. Calculado pordcTemObservacaoDescendente().
Os chips "Todas / Visíveis ao cliente / Internas" (dcObsFiltro) existem nas quatro listas e compartilham a mesma variável: filtrar numa aba filtra em todas.
Cada aba termina com um resumo próprio (.dc-obs-resumo, via renderContasObsResumo() e irmãs, chamadas no fim de cada render*(), sempre em sincronia com a tabela) e a aba "Dashboard" tem a lista consolidada das três + auditoria. As duas coisas convivem de propósito: uma é a visão de uma aba, a outra é a visão consolidada antes de gerar o relatório.
Aba "Observações" (achados)
Filtros por severidade, por status e por regra, combinados por E lógico. Como não há chip próprio para o filtro por regra, uma faixa (#dc-regra-filtro-ativo) aparece entre os chips e a lista, mostrando a regra ativa e um botão "Limpar" — sem ela não haveria como perceber por que a lista filtrou nem como sair.
Ordenação sempre por severidade (PID_DC_SEVERIDADE_ORDEM = {alta:0, media:1, baixa:2}), preservando a ordem original dentro da mesma severidade (Array.prototype.sort é estável).
Resumo no topo (.dc-achados-resumo): um donut em SVG puro com a contagem por severidade e o total no centro, mais uma grade de 3 cards por grupo temático (PID_DC_GRUPOS): "Divergências de Saldo" (regras 1-5) e "Contas Atípicas" (6-8). A regra 9 (variação atípica) não entra nesta aba, ver "Variações atípicas no fim da aba Análise Vertical". Cada card mostra o total do grupo e, por baixo, uma linha por regra (label + contagem); regra sem achado continua listada com 0, só não clicável. renderAchadosResumo() conta sempre sobre apuracaoAtual.achados completo, nunca sobre a lista filtrada — é uma visão geral estável.
Sem Chart.js aqui de propósito (essa dependência só existe no relatório estático). O donut usa a técnica clássica de
<circle r="15.9155">— circunferência ≈ 100, entãostroke-dasharray/stroke-dashoffsetjá funcionam em unidades de percentual sempathLength. Cada segmento é um<circle>próprio, clicável individualmente porquepointer-eventsde SVG só considera pixel realmente pintado pelostroke, sem hit-test manual por ângulo.
Donut, legenda e linhas de regra são clicáveis e filtram a lista abaixo, com scrollIntoView suave até ela (o resumo pode empurrar a lista para fora da tela). O cabeçalho do card de grupo não é clicável, só as linhas de regra dentro dele.
Cada card de achado pode expandir a conta citada (.dc-achado-card__toggle-conta), resolvida procurando em apuracaoAtual.contas — sem chamada de API extra. As duas regras gerais não têm conta, então não mostram o toggle.
Botão "Ver na tabela" (PID_DC_ICON_LOCALIZAR), condicionado a achado.conta != null || achado.linha_analise_vertical != null. dcIrParaLinha(tipo, id):
- Acha o índice da linha no array certo (via
DC_IR_PARA_CONFIG). - Calcula só os ancestrais dela (
dcAncestraisIds()) e tira só esses ids doSetde colapso — não a árvore inteira, preservando o resto do estado de expansão que o contador já montou. - Marca
dcFocoLinhae re-renderiza, aplicando.dc-conta-row--focosó na linha alvo. - Clica programaticamente no botão da aba certa, reaproveitando o listener de troca de aba.
- Num
requestAnimationFrame(depois do painel visível),scrollIntoView({block:"center"}). - Um
setTimeoutde 2,4s limpa o foco e re-renderiza — o pulso nunca fica grudado.
.dc-conta-row--foco anima box-shadow, não background-color (que já está em disputa entre o tingimento padrão e .dc-conta-row--destaque), então o pulso aparece por cima de qualquer estado que a linha já tenha.
Não existe "ir para a DRE" porque nenhuma regra hoje referencia
ContabilLinhaDrediretamente. Se uma regra nova precisar, o padrão se replica sem reprojetar nada: FKlinha_dre+ordem_linha_dreemAchadoDetectado+ uma entrada emDC_IR_PARA_CONFIGcomtab: "dre".
Variações atípicas no fim da aba Análise Vertical
Pedido explícito do usuário: os apontamentos de variacao_atipica_dre aparecem numa seção própria no fim da aba Análise Vertical (#dc-av-variacoes-list, renderVariacoesAnaliseVertical(), chamada no fim de renderAnaliseVertical()), cada um com "Ver na tabela", o botão de observação da linha e um botão "Validar".
- Validar trata o apontamento (
status="tratado", com quem/quando) pelo endpointvalidar/, sem o modal "Revisar". Clicar em "Validado" volta a pendente (dcValidarVariacaoBtnHtml()/dcAlternarValidacaoVariacao()). - Ícone na própria linha da tabela (pedido do usuário,
dcVariacaoIconeHtml()/PID_DC_VARIACAO_ICONE), antes do botão de validado: dourado enquanto pendente, teal depois de validada. O hover (.dc-hover-tooltip--largo) mostra título, mensagem, quem validou e até 3 observações da linha; o clique (dcIrParaVariacao()) abre a thread no card da variação, rola até ele e dá um pulso (dcVariacaoFoco, 2,4s). Validar redesenha a tabela inteira (renderAnaliseVertical()), não só a lista, para o ícone mudar de cor junto. - Não aparecem na aba Observações (pedido explícito do usuário, já estão aqui):
dcAchadosDaAbaObservacoes()tiravariacao_atipica_dreda lista, do donut e dos cards de categoria, e o grupo "Variações e Indicadores" saiu dePID_DC_GRUPOS. Continuam contando emtotal_achados_pendentesda listagem enquanto não validadas. Para a aba não parecer vazia quando só há variações, ela mostra um card "Análise Vertical" no resumo (total + pendentes de validação, só quando há variação) e uma linha clicável abaixo da lista (#dc-av-aviso,renderAvisoVariacoes()); os dois levam, pordcIrParaListaVariacoes(), à aba Análise Vertical já rolada até esta lista (#dc-av-variacoes), sem filtrar a lista de Observações. - O texto não é
observacao_contador: decisão do usuário, "os textos devem funcionar da mesma maneira que as observações feitas nas contas". O botão de observação do card abre a thread deContabilObservacaoda própria linha da Análise Vertical (histórico entre competências, "mostrar ao cliente", encerrar/excluir). É a mesma thread da tabela: escrever pelo card aparece na linha e vice-versa, e no relatório a observação sai em "Observações da Análise Vertical" quando marcada para o cliente. - A thread é montada por
dcObsPainelConteudoHtml(tipo, alvoId, observacoes, concluida, escopo):escopo="variacao"separa os ids dos campos e a chave emdcObsPainelAberto(variacao), e o atributodata-dc-obs-criarganha um terceiro pedaço com o escopo. Abrir o painel do card fecha o da tabela e vice-versa, senão a mesma thread existiria duas vezes na tela com os mesmos ids de edição.
Aba "Dashboard"
Mostra, antes de gerar o relatório, os mesmos cards de indicador e a mesma lista de observações que vão para o relatório, com um botão de olho em cada item para escondê-lo do relatório final sem apagar o dado.
dcIndicadoresAtual é buscado sob demanda só na primeira vez que a aba é aberta (e resetado para null em renderRevisao() e após qualquer criação/edição/exclusão de indicador) — evita um cálculo e uma consulta ao histórico completo toda vez que uma apuração é aberta, já que boa parte das revisões não chega a abrir essa aba. A lista de observações não tem fetch próprio, é derivada do payload já carregado.
renderDashboardIndicadores() agrupa as chaves pelo campo .grupo que vem do servidor; só a ordem dos grupos é fixa no frontend (PID_DC_DASH_ORDEM_GRUPOS, grupo desconhecido cai no fim) — é o que faz "Indicadores Personalizados" aparecer sem hardcodar nada a cada indicador criado.
Card clicável abre o modal "Ver fórmula" (funciona igual para indicador de sistema ou personalizado, já que os dois têm a mesma forma de metadado). Card de indicador personalizado ganha um botão de lápis a mais.
Botões de olho ficam disabled quando a apuração está concluída, mesmo espírito do resto.
pidDcFormatIndicadorMoeda/Percentual/Indice espelham os filtros do template em JS e sempre transformam null/undefined em "—", nunca "R$ 0,00". Não reaproveitam pidDcFormatMoeda(), que trata null como 0 de propósito (usado só em valores de conta/DRE, que nunca são None).
Resumo do Fechamento: editor contenteditable com colar/arrastar imagem (mesmo padrão de .ag-richtext/.ajuda-modal__editor, duplicado aqui de propósito — nenhum dos três é componente compartilhado; limite de 2MB por imagem). Populado só em renderRevisao(), uma vez por apuração aberta, nunca em renderDashboardTab() — resetar o innerHTML a cada troca de aba descartaria texto ainda não salvo. Sem toggle editar/visualizar: é sempre editável enquanto a apuração está em revisão, porque só o próprio contador vê esta tela (ao contrário do texto de "Mais informações", lido por todos e editado só pelo perfil "Inovação").
Modal "Novo/Editar Indicador"
.modal-card--wide, com nome/descrição/formato/ícone/fórmula técnica/fórmula de exibição e uma lista dinâmica de componentes. Cada linha tem chave + tipo e um "picker" que muda conforme o tipo: contas/variacao_conta e linha_dre reaproveitam .checklist-box/.checklist-item/.checklist-search de components.css (necessário: uma apuração real tem 100-500 contas, sem busca a lista seria inutilizável); indicador vira um <select>; resultado_liquido mostra só um texto explicativo. O picker é reconstruído só quando o tipo muda, não a cada tecla da busca (que só alterna hidden nos itens já renderizados).
Scroll: .modal-card (global, components.css) tem max-height: calc(100vh - var(--space-5)*2) + overflow-y:auto. A lista de componentes não tem scroll próprio — ter os dois aninhados por cima do .checklist-box (que já rola) deixava a área útil menor que um componente inteiro. Sobra só um scroll aninhado, o do checklist, que é genuinamente necessário.
Componente "órfão" ao editar de outra apuração: os pickers são montados a partir da apuração aberta agora, mas um componente pode ter sido configurado a partir de outra empresa/competência. Todo código/descrição salvo que não existe na apuração atual entra como item extra no topo do checklist, já marcado e com a nota "não encontrada nesta apuração, mantida" — senão salvar sem tocar naquele componente apagaria a referência em silêncio.
"Calcular com esta apuração" chama pre-visualizar-indicador/ com o formulário ainda não salvo e mostra o valor de cada componente mais o resultado final, sem persistir nada. Cada componente é formatado com indice (número BR simples, sem R$/%) porque um componente pode ser qualquer grandeza; o resultado usa o formato escolhido. O painel é escondido sempre que o modal abre, para nunca mostrar um resultado desatualizado.
Fechar pelo overlay ou "Cancelar" pede confirmação (pidConfirm, {perigoso: true}). Salvar/excluir fecham direto — não há o que descartar depois de uma ação concluída.
Lista/histórico (#dc-list-table)
Ordenação e filtro por coluna, client-side, mesmo mecanismo de #ips-list-table (Importação de Plano de Saúde), portado e renomeado com prefixo dc- — não compartilhado entre os dois arquivos JS/CSS.
carregarLista() só busca a API e guarda em dcListaApuracoes; renderList() (sem fetch) filtra, ordena e desenha — chamada por qualquer mudança de ordenação/filtro, sem round-trip.
- Ordenação padrão:
criado_emdecrescente (a última execução primeiro), decisão explícita do usuário, diferente doMeta.orderingdo model (que prioriza competência). - Colunas ordenáveis: empresa (por
codigo_empresa), competência, observações (total_achados_pendentes), status, criado_por, criado_em. - Colunas com filtro estilo Excel (funil, popup com busca + checklist): empresa, competência, status, criado_por. Ficam de fora
observacoes(contagem, não dimensão de agrupamento) ecriado_em(granularidade fina demais). valoresDistintos()ordena pelo valor bruto, não pelo rótulo formatado. Importa para competência: ordenar por "MM/AAAA" agruparia por mês antes do ano ("01/2026" antes de "12/2025"), enquanto a string ISO já ordena cronologicamente por comparação simples. O popup mostra o rótulo legível, mas indexa pelo valor bruto.- Botão "borracha" (
#dc-list-reset-btn) limpa os quatro filtros e volta a ordenação ao padrão. - Sem paginação (diferente de Importação de Plano de Saúde) — o histórico tende a ser curto, uma linha por empresa+competência.
Na linha concluída, o ícone de lixeira some (mesmo padrão do botão de reprocessar ao lado; misturar "some" e "aparece desabilitado" na mesma linha seria incoerente). O handler ainda tem try/catch + pidAlert, porque a trava do servidor continua valendo para uma lista carregada antes de outra pessoa concluir a análise.
Tooltip no visual do Portal
pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto) monta um par gatilho+texto (.dc-hover-tooltip), nunca title="..." (o balão nativo do Chrome quebra a identidade visual, mesmo raciocínio de pidConfirm/pidAlert no lugar de window.confirm/alert). Diferente de .info-tooltip de components.css por usar white-space: pre-line (preserva quebras deliberadas e envolve o resto) em vez de nowrap.
position: fixed, nãoabsolute. Os badges deste pacote vivem dentro de.pa-table-wrap, que temoverflow:hiddenpara arredondar o canto da tabela — qualquer coisaabsoluteque escape da tabela é cortada. Comfixed+top/leftcalculados em JS (pidDcPosicionaTooltip()), o balão é relativo à viewport: centralizado acima por padrão, desce quando não há espaço acima, clampado nas laterais. Delegado nodocumentcomcapture:true(mouseenter/focusnão borbulham), então um único par de listeners cobre todo badge atual e futuro, mesmo os recriados a cada re-render.
tabindex="0" no gatilho + :focus-visible mostram o tooltip por teclado também.
Não promovido para components.css por enquanto — só usado aqui, mas a mecânica é genérica se outro pacote precisar.
Relatório do cliente (dashboard-contabil-relatorio.html + views.py)
ContabilApuracaoViewSet.dashboard() gera um documento HTML autocontido — não estende o shell do Portal e nunca usa a marca "P.I.D.": carrega logo-branco.png, a identidade do escritório, porque é um documento emitido ao cliente. Ver [[feedback_logos_documentos_vs_portal]] na memória.
É GET, não POST, diferente do padrão
/gerar/das outras ferramentas. A primeira versão era POST e o frontend abria viafetch+URL.createObjectURL(blob)+window.open(). Um documento carregado de uma URLblob:tem origem sintética, e URLs relativas geradas por{% static %}não resolvem de forma confiável nesse contexto — a logo do escritório simplesmente não carregava. Com GET o frontend abre a URL da API direto (pidGerarDashboardContabil()é sówindow.open(...)), navegação de verdade, mesma origem, sem blob nem CSRF.
Abas (.dcr-tabs, JS puro inline — não reaproveita .pa-tabs, que não é carregado neste documento): Balancete (inicial) → D.R.E. → Análise Vertical (só com {% if analise_vertical_meses %}) → Resumo. O data-dcr-tab do Resumo continua "indicadores" internamente, nome anterior ao da aba ganhar mais conteúdo.
Na impressão (@media print) a barra de abas some e todas ficam visíveis, cada uma numa página (page-break-after); linhas recolhidas são forçadas a aparecer (tr[hidden] { display: table-row !important }); botões de imprimir/exportar/restaurar/voltar-ao-topo somem. Documento impresso não esconde conteúdo atrás de aba não clicada nem de grupo recolhido.
Estrutura de cada aba
Cada aba de tabela tem a seção "Observações do X" ACIMA da tabela, depois a tabela. A aba Resumo tem o Resumo do Fechamento, os grupos de cards de indicador e "Todas as Observações da Análise" (as três listas + auditoria juntas, cada item com prefixo de origem: "Balancete — ", "D.R.E. — ", "Análise Vertical — ", "Auditoria — ").
Clicar numa observação rola até a conta/linha que ela referencia, se houver. dashboard() calcula uma ancora por observação (setada no objeto Python, não é campo do model), igual ao data-dcr-id da linha correspondente, casando pela mesma chave natural. Fica None quando a conta não existe mais nesta apuração (observação histórica de conta que saiu do plano) — o "se houver": a observação continua aparecendo, só não vira link. Só o <li> com âncora ganha data-dcr-obs-ir + role="button" + .dcr-obs-item--clicavel.
Árvore recolhível server-side
Diferente da tela de revisão (SPA que re-renderiza a tabela a cada clique), aqui o HTML é gerado uma vez: nivel, tem_filhos e colapsado_padrao são calculados no servidor (_contabil_arvore_contexto(), mesmo algoritmo) e viram data-dcr-nivel/data-dcr-tem-filhos/data-dcr-id/data-dcr-colapsado-padrao em cada <tr>. pidDcrArvore(tbodyId) só alterna o atributo hidden das <tr> existentes, sem reconstruir HTML.
O relatório nasce recolhido (colapsado_padrao = tem_filhos and nivel >= _CONTABIL_NIVEL_ABERTO_PADRAO), ao contrário da tela de revisão. É a única tela onde dcColapsoPadrao ainda é padrão de abertura, e é assim de propósito: é o documento que vai ao cliente, tem só o botão de restaurar, e mudar o que o cliente vê não foi pedido.
pidDcrArvore() devolve { expandeAte, resetar }; pidDcrObs() devolve { fecharTudo }. expandeAte(id) sobe a cadeia de ancestrais e reabre só os colapsados no caminho, não a árvore inteira. resetar() devolve tudo ao estado inicial do servidor. Dois dicionários (dcrArvores/dcrObsControles) casam o tbodyId com a árvore/painel certos, então cada botão "Limpar formatação" só afeta a própria tabela.
O destaque de linha é replicado aqui com a mesma lógica da tela de revisão (calculaDestaque()/expandidos/descendentes() espelhando as funções do JS do app). Os dois lados são mantidos em sincronia manualmente — mudança na lógica de destaque precisa ser aplicada nos dois arquivos.
Observação inline no relatório: coluna "Observação" com ícone que só aparece quando há observação vigente e mostrar_ao_cliente — observação interna não vaza nem como ícone. Clicar abre uma <tr class="dcr-obs-inline-row"> já presente no HTML (nasce hidden). A visibilidade é sincronizada a todo clique no corpo da tabela (inclusive os de expandir/recolher) a partir de dois fatores: se o usuário abriu aquele painel e se a linha-pai está visível — assim, colapsar um grupo ancestral fecha os painéis abertos dentro dele sem duplicar a lógica de pilha. Essas linhas de observação são excluídas da lista que pidDcrArvore() percorre (filter por data-dcr-obs-row): incluí-las quebraria a pilha de colapso, já que não têm data-dcr-nivel próprio.
Análise Vertical: tabela larga
Essa tabela pode ter bem mais colunas (Descrição + 2 por mês + Observação = 8 para 3 meses) e .dcr-page tem max-width:1140px. Duas mudanças resolvem:
.dcr-tabela-wrap { overflow-x: auto }(eraoverflow:hidden, que zerava os dois eixos;overflow-ycontinuahidden) etable.dcr-tabela th { white-space: nowrap }— sem isso o navegador espremia as colunas até quebrar o cabeçalho em 3 linhas e cortar a última coluna para fora da área visível..dcr-tabela--compacta(só no<table>da Análise Vertical): fontes e paddings menores, ícones menores, e o filtromes_curtono cabeçalho ("mai/2026"→"mai/26"), já que "— Valor"/"— Variação" repetido por mês era o maior consumidor de largura. Objetivo: o caso comum (3 meses) caber sem rolar. Ooverflow-xcontinua como rede de segurança para mais meses.
Coluna "Observação" fixa na borda direita (as três tabelas)
Balancete e D.R.E. não são compactadas como a Análise Vertical (ver acima) porque a expectativa era que sempre coubessem em .dcr-page sem precisar rolar — na prática, uma empresa com valores grandes (muitos dígitos em Saldo Anterior/Débito/Crédito/Saldo Atual) ou plano de contas mais fundo (indentação de nivel_px na Descrição) pode ultrapassar a largura disponível, e nesse caso o overflow-x:auto do wrap entra em ação. Sem nenhum tratamento a mais, isso empurrava o botão "Restaurar formatação padrão" e o ícone de observação por linha (última coluna) pra fora da área visível — só alcançáveis arrastando a tabela pro lado, achado real do usuário contra um balancete de empresa grande.
A última <th>/<td> de cada uma das três tabelas (Balancete, D.R.E., Análise Vertical) ganhou a classe dcr-col-observacao, com position: sticky; right: 0. A coluna passa a ficar sempre colada na borda direita de .dcr-tabela-wrap, visível independente de quanto as outras colunas precisem rolar — sem precisar limitar largura/fonte das demais colunas (o que arriscaria regressão no caso comum, que já cabe). O fundo já vem de propriedades existentes (background do th genérico, --dcr-row-bg por linha no td), então não precisou de override de cor: como o valor de --dcr-row-bg é o mesmo em toda célula da mesma linha, a composição da coluna fixa sobre as células que passam por baixo dela ao rolar é visualmente idêntica a uma cor sólida. Não se aplica à <tr class="dcr-obs-inline-row"> (o <td colspan> da observação expandida) — só a linha "normal" da conta/linha tem a classe.
Filtros de template (portal_api/templatetags/contabil_extras.py)
Primeiro (e único) uso de template tags customizadas no projeto. moeda/percentual/indice/competencia no padrão brasileiro; None sempre vira "—", nunca "R$ 0,00"/"0,00%". numero_bruto devolve o valor cru só para o atributo data-count da animação, nunca para texto exibido. mes_curto para o cabeçalho da Análise Vertical.
moeda_av/percentual_av são separados de propósito: ContabilLinhaAnaliseVertical.valores grava valor/percentual como texto, então moeda (que faria f"{valor:,.2f}") falharia contra uma string, e percentual multiplicaria por 100 um número que já é um percentual pronto. Mesmo cuidado no JS: pidDcFormatPercentualAnaliseVertical() é deliberadamente diferente de pidDcFormatIndicadorPercentual() — usar o errado exibiria 10000,00%.
Um indicador personalizado chega ao contexto já formatado como texto (_contabil_formata_indicador()), porque o Django Template Language não permite escolher um filtro por nome vindo de variável.
Visual e animações
Fontes "Manrope" (títulos/cards/abas), "Inter" (corpo, font-variant-numeric: tabular-nums nas colunas de valor) e "JetBrains Mono" (fórmula do verso do card). Paleta roxo/dourado dos documentos do escritório (#3d2178/#281552/#b4872a), com gradiente e glow radial sutil no cabeçalho.
Regra de ouro para toda animação de entrada aqui: nunca fixar
opacity:0/transformcomo estilo estático fora de um@keyframes, só viaanimation: nome duração easing both;. Isso garante que@media print { * { animation: none !important; } }sozinho devolve o elemento ao estado normal na impressão, sem reset explícito por seletor. Animação nova que não siga isso pode fazer a impressão sair em branco.
Contagem animada dos cards: data-count/data-final/data-format/data-color-rule alimentam pidDcrAnimaContadores(), que anima de 0 até o valor com requestAnimationFrame, formatando os quadros intermediários com toLocaleString("pt-BR"). O texto final é sempre data-final, a string que os filtros Django já geraram — nunca um valor recalculado em JS.
Cor por sinal só onde é seguro: sign (ROA/ROE/EBIT/EBITDA — positivo verde, negativo vermelho) e liquidez (verde se ≥ 1). Kanitz, composição/grau de endividamento e IPL não ganham cor nenhuma — não existe limiar validado para eles, e colorir como "bom"/"ruim" daria falsa segurança num número que o próprio card diz ser estimativa.
Cards só animam depois da aba Resumo ser aberta (contar um número invisível não faz sentido). Isso cria um risco de impressão: se o usuário nunca abrir a aba e mandar imprimir, a contagem começaria do zero na hora da captura. O único listener de beforeprint do documento decide entre inicializar tudo já no valor final (aba nunca aberta) ou só finalizar uma contagem em andamento — nunca as duas coisas competindo.
Flip card: .dcr-card é só a cena 3D (perspective + min-height, necessário porque as duas faces são position:absolute e não contribuem para a altura); .dcr-card-inner gira no hover do pai; cada face tem backface-visibility:hidden e carrega o fundo/borda/sombra/padding. O verso mostra nome + descrição ("Sem descrição cadastrada." se vazia) + a fórmula de exibição, centralizados. Como @media print zera toda animação, a impressão sempre mostra a frente.
O relatório é uma foto estática do momento em que foi gerado. Editar a definição de um indicador depois não atualiza um relatório já aberto ou baixado — precisa clicar "Gerar Relatório" de novo. Isso já gerou uma dúvida ("o card diz 'Sem descrição cadastrada' mas o indicador tem descrição") que não era bug.
Exportações
- XLSX (
exportacao.py, funções puras com openpyxl, recebendo dataclassesLinhaBalanceteXlsx/LinhaDreXlsx, nunca os models): um botão por seção (Balancete/D.R.E.),<a href>puro sem JS. Cabeçalho mesclado (título/empresa/CNPJ/competência), linha de cabeçalho de colunas com fundo roxo, dados a partir da linha 6 comfreeze_panes. Conta sintética e linha totalizadora ganham negrito + fundo dourado claro. A hierarquia viraAlignment(indent=nivel)— não dá para reproduzir toggle numa planilha, então a árvore nasce totalmente expandida. Valores gravados comofloatcomnumber_format = '"R$" #,##0.00', continuam somáveis no Excel. - PDF do Resumo (
resumo_pdf.py, pacote puro, recebe a apuração já carregada e o dict de_contabil_dados_resumo()): Resumo do Fechamento + Indicadores + Observações, sem Balancete/D.R.E./Análise Vertical (que já têm o caminho XLSX). Banner roxo/dourado comlogo-branco.png. O texto rico do contador vira flowables do reportlab (_resumo_fechamento_flowables(), via BeautifulSoup):_inline_markup()reconstróib/i/u/braninhados na marcação que oParagraphentende (strong→b,em→i),ul/olviramListFlowable, e<img src="data:image/...">(a única forma que o editor produz) vira umImagedecodificado de base64 em memória, redimensionado para a largura útil. Seção pulada se o resumo estiver vazio.
_contabil_dados_resumo(apuracao) é compartilhada por dashboard() e resumo_pdf(): calcula os grupos de indicadores e devolve observacoes_visiveis sem separar por alvo_tipo nem calcular ancora (isso é específico do relatório HTML). dashboard() faz a separação e as âncoras por cima.
Riscos conhecidos e armadilhas
- Leiaute Contabit calibrado contra um único arquivo — ver "Leiaute Contabit": códigos das regras, passos de nível e a premissa do sinal negativo precisam ser conferidos a cada arquivo novo de outro cliente.
- Códigos de classificação fixos — se um cliente usar plano de contas com numeração diferente, indicadores e a regra de caixa saem errados silenciosamente. Revisar contra mais balancetes reais de empresas diferentes antes de confiar cegamente num valor exibido ao cliente.
- Descrições coladas em PDF de fonte atípica — não têm correção segura; a ferramenta avisa por badge. Valores monetários nunca são afetados.
- Duas cópias mantidas à mão: os SVGs de ícone (Python + JS), a lógica de árvore/destaque (tela de revisão + relatório) e a montagem do caminho na chave de observação (
chaves.caminhos_linhas()em Python,dcAplicaChavesObs()no JS, mais uma terceira cópia congelada dentro da migração0078, que por definição não pode importar código de aplicação). Mudança num lado exige o outro. - Duas constantes conceituais duplicadas:
PID_DC_NIVEL_ABERTO_PADRAO(JS) e_CONTABIL_NIVEL_ABERTO_PADRAO(Python), hoje com papéis diferentes (sob demanda na revisão, padrão no relatório). total_achados_pendentesgera N+1 na listagem de apurações.- Exclusão não mexe mais em arquivo: desde a rodada 156 do CHANGELOG desta pasta a apuração não tem
FileField, então testar operform_destroy()comtransaction.set_rollback(True)é seguro aqui. Antes, validar o caminho "204" contra uma apuração real custou o PDF anexado dela (ver[[feedback_rollback_nao_desfaz_arquivo]]na memória, que continua valendo para as aplicações que ainda gravam arquivo). - Mudança em
.pyexige reiniciar orunserverpara o usuário conseguir testar; mudança só em.html/.css/.jsnão exige. Isso já mascarou um diagnóstico ("não funciona" que não era bug de código). Ver[[feedback_py_edit_precisa_restart_runserver]]na memória.