portal_publico/portal_api/dashboard_contabil/CLAUDE.md

77 KiB
Raw Blame History

Relatório Contábil (Relatórios > Contabilidade)

Este arquivo é carregado automaticamente ao trabalhar dentro de portal_api/dashboard_contabil/. Ver CLAUDE.md na 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.md nesta 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 modelo Questor já enviado ao cliente), 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)

  • 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 natural codigo_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 de resto (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 do x0 do primeiro caractere em relação ao menor x0 da seção (raiz, nível 0), com divisor /7.0. totalizador é True quando algum caractere da linha usa fonte negrito (fontname contendo "bold") — confirmado no PDF real: "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usam Times-Bold, as demais Times-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_dre de julho é o YTD jan-jul, linhas_analise_vertical de 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_VARIACAO casa um par por vez; _RE_MES_ANALISE_VERTICAL captura 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.0 da 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).

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

  1. 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.
  2. debito_credito_divergente (alta) — soma de Débito das contas-raiz (codigo sem 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).
  3. saldo_negativo_caixa (alta) — conta com codigo começando em 1.01.01.001 (grupo Caixa) e saldo_atual < 0.
  4. 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.
  5. 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) contra linhas_dre[-1].valor. Validado batendo exato contra os 2 balancetes reais antes de entrar em produção.
  6. conta_transitoria_com_saldo (média) — descrição contém "TRANSIT" com saldo_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 PDF 1751, a fonte corrompe o acento de "TRANSITÓRIA" num caractere não recuperável — "TRANSITOR" nunca casaria com essa conta, que tem saldo real.
  7. 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.
  8. 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").
  9. 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 pelo percentual de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos VARIACAO_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.

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_together em codigo_empresa+competencia. Mais: analise_vertical_meses (JSONField, ex. ["mai/2026", "jun/2026", "jul/2026"], lista compartilhada por toda a apuração), fonte_pdf_atipica, resumo_fechamento (texto rico do contador), indicadores_ocultos e indicadores_selecionados (JSONField, listas de chave de indicador).
  • ContabilConta — uma linha do Balancete. conta_numero (numeração interna do Questor) e codigo (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. Mesmos validado/alterada_reprocessamento/valor_anterior_reprocessamento.
  • ContabilLinhaAnaliseVertical — mesma árvore/descrição/nível da DRE, mas valores (JSONField) guarda um {"valor": "...", "percentual": "..."} por mês, gravado como texto, não float, para não perder precisão; alinhado por posição com ContabilApuracao.analise_vertical_meses. valores_anterior_reprocessamento tem 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.

Apontamentos de auditoria

  • ContabilAchado — nasce automático em create(), muda de status (pendente/tratado/ignorado) via ContabilAchadoViewSet, sempre com observacao_contador obrigatória ao sair de pendente. 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 para ContabilConta) e linha_analise_vertical (FK para ContabilLinhaAnaliseVertical). 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_observacao continuam 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 por PATCH que muda texto de fato. Nunca por mostrar_ao_cliente/encerrar/reativar, e nunca quando o texto enviado é igual ao já salvo.
  • ContabilApuracaoReprocessamento — um registro por reprocessamento bem-sucedido (reprocessado_por SET_NULL, reprocessado_em auto_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 do nome na criação, nunca aceita do cliente; imutável depois, já que pode estar referenciada em indicadores_ocultos ou 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, default True), 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 um SlugField comum, porque vira nome de variável dentro da árvore ast do avaliador (diferente da chave da Definicao, 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:

  1. Lê o arquivo inteiro para memória (arquivo.read()) — nada em disco ainda.
  2. 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. ContabilExtracaoInvalidaError vira 400.
  3. Confere se já existe apuração para essa empresa+competência (.exists()) → 400 com mensagem específica, antes de qualquer escrita (evita depender do IntegrityError cru, que devolveria 500).
  4. Só então, dentro de transaction.atomic(), cria a ContabilApuracao (grava o arquivo via ContentFile) + bulk_create de contas / linhas DRE / linhas de Análise Vertical / achados. Um except Exception fora do with apaga o arquivo gravado se algo falhar no meio — upload de FileField não é transacional.

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(), troca o arquivo (apagando o antigo só depois do commit, pelo mesmo cuidado com storage não-transacional) 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 (mesmo id), nunca delete+recria. Casam cada linha extraída contra a existente por chave natural: (codigo, descricao) no Balancete, (descricao, nivel) na DRE/Análise Vertical. Casada: atualiza os campos brutos no mesmo registro (.save()); se algum campo relevante mudou, força validado=False e alterada_reprocessamento=True, guardando o valor de antes em valor_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 mapa antigo: .delete().
  • _contabil_recria_achados() — delete+recria total, o mesmo bulk_create de create(). Todo achado nasce pendente, 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:

  1. 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.
  2. 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ó ContabilObservacao atravessa competências.
  3. 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, "descricao|nivel" na DRE/Análise Vertical (chave_conta()/chave_linha() no model, _contabil_chave_alvo() na view, dcChaveObsConta() no JS — mesmo formato nos três). São exatamente as chaves que a sincronização do reprocessamento usa.

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:

  • texto só é 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" e criado_por_id == request.user.id (senão PermissionDenied). 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) juntam contas_atuais/dre_atual/resultado_liquido/historico_completo e derivam contas_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_conta somam em valor absoluto (Passivo/PL vêm negativos no relatório); linha_dre soma com o sinal já impresso (a DRE não segue essa convenção); variacao_conta é soma_atual − soma_anterior, None sem apuração anterior; indicador faz valores.get(chave); resultado_liquido resolve sempre para dados.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 componentes tipo="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) fica None para sempre, sem lançar erro — uma fórmula mal configurada não pode derrubar o relatório inteiro.

Por que existe o tipo resultado_liquido em vez de um componente linha_dre apontando 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". Um linha_dre (que casa por texto exato) quebraria assim que o resultado de uma empresa virasse de sinal entre uma competência e outra. resultado_liquido resolve 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). A formula_exibicao documenta 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_FINANCEIRAS em parser.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 filtro percentual.

dashboard_contabil/indicadores.py está 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() em views.py existe 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 o None do 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}/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:

  • ContabilApuracaoViewSet não tem PATCH genérico (http_method_names exclui "patch" de propósito). Toda edição de campo da apuração passa por uma @action dedicada que substitui aquele campo de uma vez, sempre guardada por _contabil_garante_em_revisao().
  • ContabilObservacaoViewSet não tem list/retrieve de propósito: a leitura é sempre pelo recorte de vigência de uma apuração.
  • indicadores() devolve dataclasses.asdict(...) puro, sem passar por Serializer — os Decimal/None já chegam certos no JSON porque o JSONRenderer do DRF aplica seu encoder recursivamente em qualquer estrutura de resposta, não só em campo de Serializer.
  • get_queryset() faz prefetch_related de achados__conta, achados__linha_analise_vertical, reprocessamentos__reprocessado_por (este só em list) e edicoes__editado_por nas observações. total_achados_pendentes ainda 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 o justify-content, já que este botão vem antes do rótulo). É um toggle de duas faces: mostra "−" e aplica dcColapsoPadrao() enquanto nada está recolhido; mostra "+" e zera o Set assim que existe qualquer grupo recolhido. A face sai de dcAtualizaBotaoArvore(), chamada no fim de cada render*(), e é derivada de colapsadas.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):

  • expandidos vazio → vale a leva padrão, dcUltimaLevaVisivel(itens, nivelFn, colapsadas).
  • expandidos com algo → o destaque é só a união de dcFilhosDiretos() 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ão descendentes completo. Numa árvore de 3+ níveis (mãe → filha → netos), validar os netos direto sem clicar na "filha" intermediária nunca marca o campo validado dela no banco — só o estado visual dela é "completo", calculado por render. Checar todo descendentes na "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:

  1. "nenhum" → PATCH só na própria sintética → vira "parcial".
  2. "parcial" → pidConfirm("Deseja validar todas as contas deste grupo?") → PATCH em lote de todo descendente ainda não validado → "completo". Cancelado, nada muda.
  3. "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 por dcTemObservacaoDescendente().

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), "Contas Atípicas" (6-8) e "Variações e Indicadores" (9). 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ão stroke-dasharray/stroke-dashoffset já funcionam em unidades de percentual sem pathLength. Cada segmento é um <circle> próprio, clicável individualmente porque pointer-events de SVG só considera pixel realmente pintado pelo stroke, 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):

  1. Acha o índice da linha no array certo (via DC_IR_PARA_CONFIG).
  2. Calcula só os ancestrais dela (dcAncestraisIds()) e tira só esses ids do Set de colapso — não a árvore inteira, preservando o resto do estado de expansão que o contador já montou.
  3. Marca dcFocoLinha e re-renderiza, aplicando .dc-conta-row--foco só na linha alvo.
  4. Clica programaticamente no botão da aba certa, reaproveitando o listener de troca de aba.
  5. Num requestAnimationFrame (depois do painel visível), scrollIntoView({block:"center"}).
  6. Um setTimeout de 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 ContabilLinhaDre diretamente. Se uma regra nova precisar, o padrão se replica sem reprojetar nada: FK linha_dre + ordem_linha_dre em AchadoDetectado + uma entrada em DC_IR_PARA_CONFIG com tab: "dre".

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_em decrescente (a última execução primeiro), decisão explícita do usuário, diferente do Meta.ordering do 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) e criado_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ão absolute. Os badges deste pacote vivem dentro de .pa-table-wrap, que tem overflow:hidden para arredondar o canto da tabela — qualquer coisa absolute que escape da tabela é cortada. Com fixed + top/left calculados 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 no document com capture:true (mouseenter/focus nã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 via fetch + URL.createObjectURL(blob) + window.open(). Um documento carregado de uma URL blob: 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 } (era overflow:hidden, que zerava os dois eixos; overflow-y continua hidden) e table.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 filtro mes_curto no 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. O overflow-x continua como rede de segurança para mais meses.

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/transform como estilo estático fora de um @keyframes, só via animation: 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 dataclasses LinhaBalanceteXlsx/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 com freeze_panes. Conta sintética e linha totalizadora ganham negrito + fundo dourado claro. A hierarquia vira Alignment(indent=nivel) — não dá para reproduzir toggle numa planilha, então a árvore nasce totalmente expandida. Valores gravados como float com number_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 com logo-branco.png. O texto rico do contador vira flowables do reportlab (_resumo_fechamento_flowables(), via BeautifulSoup): _inline_markup() reconstrói b/i/u/br aninhados na marcação que o Paragraph entende (strong→b, em→i), ul/ol viram ListFlowable, e <img src="data:image/..."> (a única forma que o editor produz) vira um Image decodificado 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

  • 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) e a lógica de árvore/destaque (tela de revisão + relatório). 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_pendentes gera N+1 na listagem de apurações.
  • Testar exclusão nesta ViewSet destrói arquivo de verdade: perform_destroy() chama instance.arquivo.delete(save=False), e apagar arquivo do storage não é revertido por transaction.set_rollback(True) — o padrão de teste usado no resto desta documentação protege só o banco. Validar o caminho "204" contra uma apuração real já custou o PDF anexado dela (o registro voltou pelo rollback, o arquivo não). Nenhum dado analítico depende desse arquivo (arquivo não é exposto em serializer nenhum e reprocessar() sempre grava um upload novo), mas um teste futuro precisa de apuração descartável ou storage isolado. Ver [[feedback_rollback_nao_desfaz_arquivo]] na memória.
  • Mudança em .py exige reiniciar o runserver para o usuário conseguir testar; mudança só em .html/.css/.js nã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.