portal_publico/portal_api/dashboard_contabil/CLAUDE.md

113 KiB
Raw Blame History

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

Movido do CLAUDE.md da raiz — 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.).

Renomeado de "Dashboard Contábil" pra "Relatório Contábil" numa rodada específica — pedido explícito do usuário, pra soar como um aliado do trabalho do contador em vez de mais um processo/sistema novo. Rename só de rótulo visível: menu (catalogo.py), <title>/<h1>/cabeçalhos de dashboard-contabil.html e dashboard-contabil-relatorio.html, o botão que gera o relatório ("Gerar Relatório", antes "Gerar Dashboard") e verbose_name/verbose_name_plural dos models no admin. Nada técnico mudou: a pasta continua dashboard_contabil/, os arquivos continuam dashboard-contabil.*, as classes continuam Contabil*/IndicadorContabil*, as rotas continuam /api/contabil-*//api/contabil-apuracoes/{id}/dashboard/, a permissão continua apps["dashboard-contabil"]. Não seguir esse rename pra dentro do código — só onde o texto é literalmente visível ao usuário (ou documentação, como este arquivo).

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 hoje enviado ao cliente), a ferramenta extrai as contas/linhas e roda um conjunto de checagens automáticas de auditoria, apresentando os achados numa tela de revisão onde o contador analisa, registra observações e conclui a análise. Permissão de toggle único (apps["dashboard-contabil"] em permissoes["relatorios"], subgrupo "Contabilidade"), checada via PermissaoApp("relatorios", "dashboard-contabil") em todos os ModelViewSet relacionados. Nasce restrita só ao perfil "Inovação" (código 8) — mesmo padrão de "Não Conformidades" (ver override em seed_portal.py), já que expõe balancete/DRE completos dos clientes.

O botão "Gerar Dashboard" (relatório final em HTML para o administrador da empresa) já está implementado — ver "Relatório 'Gerar Dashboard'" abaixo, que inclui exportação de Balancete/DRE em XLSX a partir dele (exportacao.py, ver "Exportação em XLSX"). Ainda fora de escopo: consolidação entre várias empresas/competências ao mesmo tempo (o BI Contábil externo que este Dashboard substitui tem filtros "Ano-Mês"/"Empresa-Filial" que sugerem isso, mas o escopo confirmado com o usuário é sempre uma apuração por vez — ver seção própria).

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, sem o risco de parsing de texto), levantado como alternativa — o usuário optou por manter só PDF, como pedido originalmente. Não há suporte a XLSX nesta ferramenta.
  • Histórico para variação mês a mês fica no próprio Portal, não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal" (que é só um relatório complementar, derivado do balancete, enviado ao cliente separadamente — não é upload desta ferramenta). Cada apuração processada fica salva (ContabilApuracao, chave natural codigo_empresa+competencia), e as regras de variação comparam contra as apurações anteriores da mesma empresa já no banco.
  • Escopo das regras: só o que é derivável do próprio balancete/DRE anexado — nenhuma checagem do ITD que dependa de sistemas externos (Questor, extratos bancários, folha de pagamento, PID legado). A ferramenta é analítica ("Auditoria de Balancetes" em Auditorias > Fisco/Contábil, hoje só um placeholder href="#" no menu, referencia o antigo sistema PID legado — não confundir com este Dashboard Contábil, são coisas diferentes), não substitui as etapas operacionais do roteiro (zeramento de saldos etc.).

Extração do PDF (parser.py)

O relatório Questor de Balancete + DRE tem uma particularidade de renderização que quebra a extração padrão do pdfplumber: cada caractere do texto real é desenhado em posição própria (sem kerning) e, por baixo dele, o PDF inclui uma grade densa de caracteres de espaço cobrindo toda a largura de cada linha — um artefato do gerador de relatório, não intencional para leitura. Isso faz page.extract_words()/page.extract_text() do pdfplumber tratarem esses espaços "de fundo" como separadores de palavra reais, quebrando números em dígitos isolados (ex.: "34.245.469,57" vira uma sequência de tokens '3', '4', '.', '2'...).

_reconstroi_linhas() contorna isso trabalhando direto com page.chars: ignora todo caractere de texto igual a " " e reconstrói cada linha a partir da posição real (x0/x1) dos caracteres não-espaço, reinserindo um único espaço só quando o vão horizontal entre dois caracteres consecutivos ultrapassa _GAP_ESPACO (0.8pt) — calibrado contra 792 - balancete 072026.pdf (arquivo de referência do usuário, salvo fora do repositório em Projetos\Balancetes): o vão dentro de uma palavra/número é ~0, entre duas palavras da mesma descrição é ~1.7-1.9pt, e entre campos da tabela (conta → flag S/A → código → descrição, ou entre colunas de valor) é sempre ≥5pt. Essa reconstrução foi validada rodando de fato contra o PDF real antes de escrever o parser definitivo (nunca desenhar regex só de texto colado — mesmo cuidado documentado na skill importacao-plano-saude).

  • Balancete: cada linha casa com _RE_LINHA_BALANCETE (^(conta)\s+(S)?\s*(código)\s+(resto)$), e os últimos 4 tokens monetários de resto (via _RE_MONETARIO) são Saldo Anterior/Débito/Crédito/Saldo Atual, nessa ordem — o texto antes deles é a descrição. tipo é "S" (sintética) quando o flag aparece, "A" (analítica) quando não.
  • DRE: cada linha é descrição + um único valor final (sem código de classificação, diferente do Balancete). nivel (indentação) é derivado do x0 do primeiro caractere da linha, em relação ao menor x0 visto na seção (a raiz, nível 0); totalizador é True quando algum caractere da linha usa fonte em negrito (fontname contendo "bold", case-insensitive) — confirmado contra o PDF real: linhas como "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usam Times-Bold, as demais Times-Roman.
  • Demonstração Mensal (Análise Vertical) (páginas finais do mesmo PDF, quando presentes) — passou a ser extraída (rodada 123; antes o parser parava aí de propósito, já que o histórico próprio do Portal cobre variação mês a mês de forma mais confiável). É a mesma árvore da DRE (mesma descrição/ordem/negrito), só que cada linha repete N pares "Valor Variação" (um por mês mostrado, ex. "mai - 2026 jun - 2026 jul - 2026" — normalmente os 3 meses até a competência do PDF) em vez de um valor único. _RE_PAR_VALOR_VARIACAO casa um par de cada vez ((valor, percentual), na ordem em que aparecem na linha); _RE_MES_ANALISE_VERTICAL captura o cabeçalho de mês uma única vez (primeira página da seção — as páginas seguintes repetem o mesmo cabeçalho, ignorado depois da primeira captura). O nível de indentação usa o mesmo divisor /7.0 da DRE, calibrado contra o mesmo PDF de referência. Ver "Análise Vertical" mais abaixo pra models/views/frontend/relatório.
  • LINHA_DRE_RECEITA_LIQUIDA/LINHA_DRE_CUSTOS_TOTAIS (constantes em parser.py) guardam o texto exato dessas duas linhas totalizadoras (com o prefixo "(=) "/"(-) " que o Questor imprime) — usadas por regra_percentual_custo_receita_atipico pra achar a linha certa por texto, mais robusto a pequenas variações de nível/indentação entre empresas do que confiar só no negrito.
  • extrai_balancete_dre(origem) aceita tanto um caminho em disco quanto um arquivo já aberto em memória (io.BytesIO) — a view chama isto antes de salvar qualquer coisa no banco, já que codigo_empresa/competencia (a chave natural da apuração) só são conhecidos depois de ler o PDF, não informados pelo usuário no upload (diferente de IndicadorApuracao, que recebe a competência como campo do formulário).

Regras de auditoria v1 (regras.py)

Cada regra_* é uma função pura: (ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]. historico vem ordenado da apuração mais recente pra mais antiga (só as últimas 2 já persistidas da mesma empresa, resolvidas por _contabil_monta_historico() em views.py).

  1. balanceamento_ativo_passivo (alta) — soma do grupo Ativo (codigo="1") deve fechar com a do Passivo (codigo="2", já vem negativo no relatório).
  2. debito_credito_divergente (alta) — soma de Débito das contas-raiz (codigo sem ponto, ou seja só "1" e "2") deve bater com a soma de Crédito. Não é uma checagem trivial de "todo balancete sempre bate": como a DRE (Resultado) 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 também transita pelas contas de Patrimônio Líquido do Passivo (ex.: "LUCROS/PREJUÍZOS DO EXERCÍCIO") — confirmado empiricamente contra 792 - balancete 072026.pdf (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. conta_transitoria_com_saldo (média) — descrição contém "TRANSIT" (cobre "TRANSITÓRIA"/"TRANSITORIA") com saldo_atual != 0.
  5. 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).
  6. saldo_sinal_invertido (média) — conta analítica (tipo="A") do Ativo (1.) com saldo credor, ou do Passivo (2.) com saldo devedor, exceto contas redutoras (descrição começando com "(-)", que são esperadas ter o sinal oposto ao grupo).
  7. descricao_generica (baixa) — descrição exatamente "DIVERSOS" com saldo relevante (o ITD cita esse caso especificamente: "o contador deverá realocar estes lançamentos a conta pertinente").
  8. variacao_atipica_saldo (média) — compara saldo_atual de cada conta analítica contra a apuração anterior da mesma empresa (por codigo), sinaliza quando a variação passa de VARIACAO_LIMIAR_PERCENTUAL (50%) e VARIACAO_VALOR_MINIMO (R$1.000, evita ruído em contas de valor irrisório). Só roda a partir da 2ª apuração de uma empresa.
  9. variacao_atipica_dre (média) — mesma ideia, mas isolando o mês: como a DRE do relatório é acumulada desde janeiro, _isola_mes_dre() subtrai o acumulado do mês anterior (valor_ytd_atual - valor_ytd_anterior), exceto em janeiro (onde o acumulado já é o próprio mês). Só roda quando há 2 apurações anteriores consecutivas (_mes_consecutivo()) — precisa isolar tanto o mês atual quanto o anterior pra comparar mês-contra-mês de verdade, não acumulado-contra-acumulado.
  10. percentual_custo_receita_atipico (média) — compara o percentual Custos/Receita Líquida do mês isolado atual contra o do mês isolado anterior (mesma técnica da regra 9) — o ITD é explícito que não existe parâmetro geral entre empresas pra essa relação, só comparação histórica da própria empresa.

Regras 8-10 não geram achado nenhum na primeira apuração de uma empresa (sem histórico ainda) — comportamento esperado, não bug.

Models (portal_api/models.py)

Padrão cabeçalho → linhas de detalhe → achados (mesma filosofia de IndicadorApuracao/IndicadorApuracaoColaborador):

  • 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 — reprocessar a mesma competência de uma empresa exige excluir a apuração antiga primeiro (sem "reabrir"/reprocessar nesta v1, diferente de ImportacaoPlanoSaude).
  • ContabilConta: uma linha do Balancete. observacao (TextField, editável via PATCH em qualquer conta, tenha ela gerado achado ou não) — é o espaço de "análise" pedido pelo usuário, independente da auditoria automática. oculta_no_relatorio (default True, invertido numa rodada — ver "Editor de observação inline" abaixo) e validado (BooleanField, default False — checkbox informativo de "já conferi esta conta", sem efeito em achado/observação/relatório).
  • ContabilLinhaDre: uma linha da DRE, sem código de classificação (o relatório não traz um pra DRE, diferente do Balancete). Mesmos oculta_no_relatorio/validado de ContabilConta.
  • ContabilLinhaAnaliseVertical (rodada 123): uma linha da Demonstração Mensal (Análise Vertical) — mesma árvore/descrição/nível da DRE, mas valores (JSONField) guarda um {"valor": "...", "percentual": "..."} por mês em vez de um DecimalField único (gravado como texto, não float, pra não perder precisão), alinhado por posição com ContabilApuracao.analise_vertical_meses (ex.: ["mai/2026", "jun/2026", "jul/2026"], lista compartilhada por toda a apuração, não por linha). Mesmos observacao/oculta_no_relatorio/validado de ContabilConta/ContabilLinhaDre — recursos por linha idênticos (editor inline, tri-state, ocultar do relatório), decisão confirmada com o usuário via AskUserQuestion antes de implementar. Lista vazia (analise_vertical_meses=[], nenhuma linha) quando o PDF não tinha essa seção — relatório antigo, ou empresa sem essa seção habilitada no Questor; a aba/tab correspondente some nesse caso (ver "Análise Vertical" abaixo).
  • ContabilAchado: achado de auditoria, nasce automático em create(), nunca é apagado — só muda de status (pendente/tratado/ignorado), sempre com observacao_contador obrigatória ao mudar de pendente (mesmo espírito de "histórico completo preservado" de ImportacaoPlanoSaudeAuditoria). conta é nullable — achados 1 e 2 (balanceamento/débito-crédito) são gerais, sem uma conta específica.

ContabilApuracaoViewSet.create() — ordem de operações não-trivial

Diferente de IndicadorApuracaoViewSet/ImportacaoPlanoSaudeViewSet (onde a chave natural do registro, ex. competencia, vem do formulário do usuário), aqui codigo_empresa/competencia só são conhecidos depois de extrair o PDF. A ordem em create():

  1. Lê o arquivo inteiro pra memória (arquivo.read()) — nada em disco ainda.
  2. dashboard_contabil_pipeline.processa_apuracao(io.BytesIO(conteudo), _contabil_monta_historico) — extrai o cabeçalho/contas/DRE e, com o codigo_empresa/competencia já em mãos, chama _contabil_monta_historico() (função injetada, consulta o ORM) pra buscar até 2 apurações anteriores da mesma empresa, então roda as regras. Captura ContabilExtracaoInvalidaError → 400 genérico.
  3. Confere se já existe uma apuração pra essa empresa+competência (.exists()) → 400 com mensagem específica, antes de qualquer escrita (evita depender só do IntegrityError do banco, que devolveria um 500 cru).
  4. Só agora, dentro de transaction.atomic(), cria ContabilApuracao (grava o arquivo via ContentFile(conteudo, ...)) + bulk_create de contas/linhas DRE/achados. 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 uma função (não uma lista já pronta) exatamente por essa dependência: a chave de busca do histórico só existe depois da extração, então não dá pra pré-buscar antes de chamar o pipeline como as outras duas ferramentas fazem.

Frontend

templates/dashboard-contabil.html (page-content--wide) segue o padrão de 3 sub-views de indicador-desempenho.html: #dc-list-view (histórico + botão "Nova Análise") / #dc-form-view (upload de um único PDF — sem campo de competência, é extraído do arquivo) / #dc-review-view (abas Achados/Balancete/DRE, via .pa-tabs/.pa-tab-panel de perfis-acesso.css). Achados têm filtro por severidade e por status (pendentes/todos); tratar/ignorar um achado abre um modal próprio (#dc-achado-modal) que exige observação não-vazia. Observação de conta/linha da DRE não abre mais modal nenhum — editor inline na própria tabela, ver "Editor de observação inline" abaixo. Botão "Gerar Relatório" (#dc-gerar-dashboard-btn, id não mudou) chama pidGerarDashboardContabil() — ver "Relatório 'Gerar Dashboard'" abaixo.

Balancete e DRE usam a mesma árvore recolhível (dashboard-contabil.js): o Balancete já construía uma árvore expansível a partir do nível de indentação derivado do código de classificação (dcContaNivel(), contando segmentos separados por .) — a DRE não tem código de classificação (ver "Extração do PDF" acima), mas já carregava nivel pronto do backend (ContabilLinhaDre.nivel, derivado do x0 de cada linha no PDF), então renderDre() reaproveita exatamente o mesmo algoritmo de renderContas() (pilha de níveis recolhidos, "tem filhos" = a próxima linha tem nível maior) só que sobre linha.nivel direto, sem precisar de um dcContaNivel equivalente. Reaproveita as mesmas classes CSS do toggle (.dc-conta-toggle/.dc-conta-toggle-spacer/.dc-conta-desc-cell, dashboard-contabil.css) — o nome genérico ("conta") já cobre as duas árvores, não precisou de classe nova. Estado de colapso é independente por aba (dcContasColapsadas/dcDreColapsadas, dois Set()).

Nasce recolhida a partir do 3º segmento do código (pedido explícito do usuário, pra reduzir a poluição visual de uma apuração com muitas contas analíticas — limiar ajustado numa rodada seguinte, ver abaixo): em vez de renderRevisao() reiniciar os dois Set() vazios (tudo expandido), dcColapsoPadrao(itens, nivelFn) os pré-popula com os ids de todo item que tem filhos e está no nível PID_DC_NIVEL_ABERTO_PADRAO (2) ou além — ex. a conta 1.01.01 (3 segmentos, nível 2) aparece aberta, mas seus filhos (1.01.01.001, nível 3) ficam ocultos até o contador clicar pra expandir; se expandido, um filho de nível 3 que também tenha netos nasce recolhido de novo pelo mesmo critério, então "nível 3 em diante" precisa sempre de um clique a mais, não só a primeira camada. Mesmo limiar aplicado à DRE, sobre Math.max(0, linha.nivel) — decisão confirmada com o usuário (a princípio o pedido citava só o Balancete, mas como a DRE reaproveita o mesmo algoritmo, o mesmo comportamento faz sentido nela também). O relatório "Gerar Dashboard" replica esse mesmo estado inicial (ver abaixo) — não é só a tela de revisão. PID_DC_NIVEL_ABERTO_PADRAO (JS) e _CONTABIL_NIVEL_ABERTO_PADRAO (views.py) são a mesma constante conceitual duplicada nos dois lados (um é SPA, o outro HTML renderizado uma vez) — mudar o limiar exige ajustar os dois.

Destaque acompanha o último grupo expandido (pedido explícito do usuário, mesma rodada do ajuste de limiar acima): ao expandir uma conta/linha, as linhas que acabaram de ficar visíveis (só os filhos diretos, não os netos — que continuam recolhidos pelo limiar acima) ganham um realce dourado (.dc-conta-row--destaque); se o contador expandir uma dessas linhas em seguida, o realce se move pros filhos dela, e o grupo destacado antes volta pra cor padrão — nunca acumula mais de uma "leva" destacada ao mesmo tempo. dcContasDestaque/dcDreDestaque (dois Set(), um por árvore, resetados em renderRevisao()) guardam só a leva mais recente; dcFilhosDiretos(itens, nivelFn, id) (função genérica, reaproveitada pelas duas árvores) varre a lista plana a partir do índice do item expandido e para no primeiro item de nível igual ou menor (fim do grupo), coletando só os de nível exatamente pai+1. O handler de clique do toggle decide o destino do destaque antes de checar se a ação foi expandir ou recolher (estavaColapsada = colapsadas.has(id) guardado antes de mutar o Set): expandir substitui dcContasDestaque/dcDreDestaque pelos filhos diretos recém-revelados; recolher só limpa (new Set()), já que nada novo foi exposto. Puramente visual, sem persistência — reseta a cada apuração aberta, sem afetar nenhum dado gravado.

Card de achado expande a conta usada no apontamento (renderAchados(), pedido explícito do usuário): achado.conta (id, já vem no payload de /api/contabil-apuracoes/{id}/, junto de conta_codigo/conta_descricao) é resolvido pra objeto completo procurando em apuracaoAtual.contas (.find((c) => c.id === achado.conta)) — sem chamada de API extra, já que a apuração inteira (contas + linhas de DRE + achados) já vem de uma vez só nesse endpoint. Só achados vinculados a uma conta específica ganham o botão "Ver conta usada no apontamento" (.dc-achado-card__toggle-conta) — as duas regras gerais (balanceamento_ativo_passivo/debito_credito_divergente, conta nulo no model) não têm uma conta única por trás, então não mostram o toggle. Expandido, mostra código/descrição/saldo anterior/débito/crédito/saldo atual da conta (.dc-achado-card__conta, um <dl> em grid). Estado de expansão (dcAchadosContaExpandida, um Set() de ids de achado) segue o mesmo padrão de dcContasColapsadas/dcDreColapsadas — reiniciado em renderRevisao().

Lista de Observações ordenada por severidade (pedido explícito do usuário): achadosFiltrados() filtra e depois ordena (PID_DC_SEVERIDADE_ORDEM = {alta: 0, media: 1, baixa: 2}) — Alta sempre primeiro, Baixa por último, preservando a ordem original (ordem em que as regras rodaram) dentro de uma mesma severidade, já que Array.prototype.sort é estável. Puramente ordenação de exibição no frontend, nada mudou no backend/model.

Resumo clicável (donut por severidade + cards por grupo temático) no topo da aba Observações (.dc-achados-resumo, dashboard-contabil.html/.css/.js, pedido explícito do usuário, inspirado numa tela de auditoria de outro sistema — ver rodadas 97/98 do CHANGELOG.md): acima dos chips de filtro, um donut em SVG puro (sem Chart.js — essa dependência só existe no relatório estático "Gerar Dashboard", não faz sentido carregar aqui numa tela interativa pequena) com a quantidade de observações por severidade (Alta/Média/Baixa, mesmas cores dos badges — --danger/--gold/rgb(var(--slate-rgb))) e o total no centro, mais uma grade de cards. PID_DC_REGRAS (constante no topo do arquivo) enumera as 10 regras de regras.py por chave (achado.regra, campo que já existia no model ContabilAchado).

Cards agrupados por tema, não um card por regra (rodada seguinte, pedido explícito do usuário: a versão original — um card por regra, 10 ao todo, cada um com uma mini-tabela de conta+status por achado — ficou "muito poluída visualmente"). PID_DC_GRUPOS (constante logo abaixo de PID_DC_REGRAS) agrupa as 10 regras em 3 temas fixos: "Divergências de Saldo" (balanceamento Ativo x Passivo, débito x crédito, caixa negativo, sinal de saldo invertido), "Contas Atípicas" (contas transitórias, contas que deveriam zerar, descrição genérica) e "Variações e Indicadores" (variação atípica de saldo, variação atípica na DRE, percentual custo/receita) — agrupamento alinhado com o usuário antes de implementar (via pergunta com preview), não inferido sozinho. Cada card mostra só o total do grupo no cabeçalho e, por baixo, uma linha por regra (label + contagem, sem mini-tabela de conta/status) — regra sem nenhum achado nesta apuração continua listada com contagem 0, só sem estar clicável (mesmo espírito de sempre mostrar as 10 regras, mesmo as que "passaram"). O detalhe por conta/status de cada achado (antes replicado dentro de cada card) não foi removido, só saiu do resumo — continua disponível na lista completa logo abaixo, ao clicar numa linha de regra pra filtrar. renderAchadosResumo() (chamada no início de renderAchados(), então atualiza sozinha a cada mudança de status/filtro) conta sempre sobre apuracaoAtual.achados completo, nunca sobre achadosFiltrados() — é uma visão geral estável, não deve mudar quando o usuário filtra a lista detalhada logo abaixo. As cores dos 3 grupos (PID_DC_CATEGORIA_CORES, mesma constante de antes, agora indexada por grupo em vez de por regra) reaproveitam os tokens --accent-rgb/--danger-rgb/--gold-rgb de tokens.css (já theme-aware, acompanham tema claro/escuro e a cor de tema escolhida pelo usuário), sem nenhum hex novo hardcoded. Decisão explícita de escopo (alinhada por pergunta ao usuário antes de implementar): não foi replicado o checklist pass/fail de checagens do sistema de referência (várias delas — folha, vencimento de fornecedor/cliente/imposto, saldo bancário — dependem de dado fora do Balancete/DRE anexado, fora do escopo já documentado desta ferramenta, ver "Decisões de escopo" acima) nem essa visualização foi levada pro relatório "Gerar Dashboard" (só a tela de revisão do Portal).

Donut e linhas de regra são clicáveis, filtram a lista detalhada abaixo (pedido explícito do usuário, rodada 98; a granularidade do clique por regra individual foi preservada na reorganização em grupos da rodada seguinte): cada fatia do donut (ou item da legenda) chama pidDcSelecionarSeveridade(severidade) — a mesma função que os chips "Alta"/"Média"/"Baixa" já usavam (extraída pra função reaproveitável, sem duplicar a lógica de atualizar filtroSeveridade+classe .is-active+renderAchados()); clicar numa linha de regra com contagem > 0 alterna filtroRegra (achado.regra exata ou null) — clicar de novo na mesma linha limpa o filtro; o card do grupo em si (cabeçalho) não é clicável, só as linhas de regra dentro dele. achadosFiltrados() ganhou uma terceira condição (filtroRegra) que se combina por E lógico com severidade/status já existentes — os três filtros funcionam juntos, não um substitui o outro. Como não existe um chip próprio pro filtro por categoria, uma faixa nova (#dc-regra-filtro-ativo, escondida quando filtroRegra é null) aparece entre os chips e a lista mostrando o nome da regra ativa + um botão "Limpar" — sem essa faixa não haveria como o usuário perceber por que a lista ficou filtrada nem como sair do filtro sem adivinhar que precisa clicar de novo na linha. Clicar em qualquer um dos dois (donut/linha de regra) também dá um scrollIntoView suave até #dc-achados-list (pidDcScrollParaLista()), já que o resumo pode empurrar a lista pra fora da tela em telas menores. O donut usa a técnica clássica de pizza/donut em SVG com <circle r="15.9155"> (circunferência ≈ 100, então stroke-dasharray/stroke-dashoffset já funcionam direto em unidades de percentual, sem precisar de pathLength) — cada segmento é um <circle> próprio com seu stroke-dashoffset acumulado (offset inicial 25 desloca o início de "3 horas" pra "12 horas"), clicável individualmente porque pointer-events de SVG só considera pixel realmente pintado pelo stroke, sem precisar de hit-test manual por ângulo.

Editor de observação inline + checkbox "validado" (rodada de "aliado do contador"): pedido explícito do usuário pra reduzir a fricção da revisão — o antigo #dc-observacao-modal (popup único reaproveitado por conta/linha) foi removido; clicar no ícone de observação agora abre uma <tr class="dc-obs-edit-row"> extra logo abaixo da própria linha, dentro da mesma tabela (renderContas()/renderDre(), dashboard-contabil.js), com um <textarea>, um checkbox "Mostrar esta observação ao cliente no relatório" e os botões Cancelar/Salvar — motivo do usuário: um clique acidental fora do popup não deve mais descartar o texto em digitação, e ver a conta ao lado da observação ajuda a não perder o contexto. Estado de qual editor está aberto (no máximo um por tabela) fica em dcContaObsEditId/dcDreObsEditId (module-level, resetados em renderRevisao()), seguindo o mesmo padrão de dcContasColapsadas/dcAchadosContaExpandida. Salvar chama pidAtualizarObservacaoContaContabil(id, {observacao, oculta_no_relatorio})/a equivalente de DRE — os dois campos juntos numa única chamada PATCH, já que "mostrar ao cliente" é decidido no mesmo instante em que a observação é escrita; oculta_no_relatorio agora nasce True por padrão no model (antes False) — uma observação nova só vai pro relatório do cliente depois que o contador marcar o checkbox explicitamente, nunca por padrão (migração 0066, só o default mudou, sem reescrever linhas já existentes).

Segundo botão ao lado do de observação (mesma célula, .dc-obs-cell-actions): um ícone de check (.dc-conta-validado-btn, cor --teal quando marcado — deliberadamente não --accent, que já é usado pra "observação preenchida" e também é a cor de tema escolhida pelo usuário, ver "Temas de cor" no tokens.css) pro campo novo validado — puramente informativo ("já conferi esta conta/linha durante a revisão"), sem gate em nada (não bloqueia conclusão, não afeta achado/relatório). PATCH via pidAtualizarValidadoContaContabil/...LinhaDreContabil.

Tri-state numa conta/linha sintética (com filhos) (pedido explícito do usuário, rodada seguinte): uma folha continua um toggle simples (verde/cinza), mas uma sintética passa a ter 3 estados, calculados a cada render a partir dos descendentes (todos, não só os filhos diretos — dcDescendentes(itens, nivelFn, id), generaliza dcFilhosDiretos pra não parar no primeiro nível) via dcEstadoValidacaoGrupo(item, descendentes):

  • "nenhum" (cor padrão) — nem a sintética nem nenhum descendente está validado.
  • "parcial" (--gold, mesmo amarelo dos badges de severidade média) — qualquer combinação intermediária, inclusive só a própria sintética marcada (1º clique) sem nenhum descendente ainda.
  • "completo" (--teal, mesma cor de uma folha validada) — todo descendente está validado, checado primeiro e independente do campo validado da própria sintética (o que importa é "está tudo marcado embaixo", não se o cabeçalho do grupo foi clicado).

dcClicarValidadoConta(id)/dcClicarValidadoLinha(id) (novas, chamadas pelo listener de clique de [data-dc-conta-validado]/[data-dc-dre-validado]) implementam o ciclo de 3 cliques pedido pelo usuário, recalculando o estado a cada clique (nunca guardado à parte):

  1. "nenhum" → PATCH só na própria sintética (validado=true) → vira "parcial" (a menos que, coincidentemente, todo descendente já estivesse validado).
  2. "parcial" → pidConfirm("Deseja validar todas as contas deste grupo?") → se confirmado, PATCH em lote (Promise.all) de todo descendente ainda não validado (mais a própria sintética, se ainda não) → vira "completo". Se cancelado, nada muda.
  3. "completo" → PATCH em lote desmarcando a própria sintética + todos os descendentes, sem perguntar (pedido explícito: "apertar novamente desmarca todo o grupo").

Cada PATCH é individual (ContabilContaViewSet/ContabilLinhaDreViewSet continuam sem uma action de lote) — o "lote" é só client-side via Promise.all, aceitável dado que um grupo real tem no máximo algumas dezenas de contas. Todo caminho termina chamando renderContas()/renderDre() inteiro (abandonando o ajuste direto no DOM que existia antes só pro caso de folha) — necessário porque o estado de uma sintética ancestral também pode ter mudado de cor e precisa recalcular, o que só um re-render completo garante; o efeito colateral aceito é que um editor de observação aberto numa outra linha perde o texto ainda não salvo nesse recálculo (like-for-like com o comportamento já aceito ao alternar entre linhas, ver "Editor de observação inline" acima).

Resumo de observações no final de cada aba (pedido explícito do usuário: "inclua no final das páginas um resumo da quantidade de observações e as observações realizadas"): Balancete e DRE ganharam cada um sua própria seção .dc-obs-resumo logo abaixo da tabela (renderContasObsResumo()/renderDreObsResumo(), chamadas no fim de renderContas()/renderDre() — sempre em sincronia com a tabela) com a contagem (<span class="dc-obs-resumo__count">) e a lista de observações já registradas daquela aba, cada uma com o mesmo botão de olho (mostrar/ocultar do relatório) que já existia na lista combinada da aba "Dashboard". É adicional, não substitui: a lista combinada de #dc-dash-observacoes-list (Balancete + DRE + Auditoria juntos) continua existindo do jeito que estava — decisão confirmada com o usuário, já que cada resumo serve um propósito diferente (visão específica de uma aba vs. visão consolidada antes de gerar o relatório). pidDcObsItemHtml() (função nova) fatora a marcação de um item de lista (.dc-dash-obs), reaproveitada pelos dois resumos novos — a lista combinada da aba "Dashboard" manteve sua própria montagem inline (precisa do rótulo de origem "Balancete"/"D.R.E."/"Auditoria" e de uma chave composta tipo:id, que os resumos por aba não precisam por já serem de um tipo só).

Ícone de observação + painel inline também no relatório do cliente (pedido explícito do usuário, "assim como temos na aplicação"): as tabelas de Balancete/DRE do relatório ganharam uma coluna "Observação" (igual à da tela de revisão) com um ícone que só aparece quando item.conta.observacao/item.linha.observacao está preenchida e oculta_no_relatorio é False (a mesma condição de observacoes_contas/observacoes_dre no contexto do view, ver "Relatório 'Gerar Dashboard'" abaixo) — uma observação marcada como não-visível ao cliente não aparece nem como ícone. Clicar abre um <tr class="dcr-obs-inline-row"> já presente no HTML (nasce hidden) logo abaixo da conta/linha, com o texto puro (sem |safe, é TextField simples, não rich text). pidDcrObs(tbodyId) (nova função em dashboard-contabil-relatorio.html, chamada logo depois de pidDcrArvore(tbodyId) pro mesmo tbody) controla o abrir/fechar; a visibilidade da linha de observação é sincronizada (sincroniza(), chamada a todo clique no corpo da tabela, inclusive os de expandir/recolher grupo) a partir de dois fatores: se o usuário marcou aquele painel como aberto E se a linha-pai (o previousElementSibling) está visível — assim, colapsar um grupo ancestral também fecha visualmente qualquer painel de observação aberto dentro dele, sem duplicar a lógica de pilha/nível de pidDcrArvore. Importante: essas linhas de observação são excluídas da lista linhas que pidDcrArvore() percorre (filter por data-dcr-obs-row) — incluí-las quebraria a pilha de colapso, já que elas não têm data-dcr-nivel próprio.

Relatório "Gerar Dashboard" (indicadores.py + dashboard-contabil-relatorio.html)

ContabilApuracaoViewSet.dashboard() (GET /api/contabil-apuracoes/{id}/dashboard/, mesma permissão de toggle único das outras actions) gera um documento HTML autocontido (não estende o shell do Portal — nunca a marca "P.I.D.", ver "Logos" no CLAUDE.md raiz) com os indicadores financeiros, a DRE/Balancete agrupados por nível (recolhível, ver "Árvore recolhível" abaixo), um gráfico de evolução do Resultado Líquido e as observações que o contador já registrou (contas, linhas de DRE, achados tratados/ignorados com observacao_contador). Escopo confirmado com o usuário: sempre uma apuração por vez (a que está sendo revisada), sem "Filial" (não existe no modelo) nem consolidação entre empresas — isso ficaria pra um BI à parte, fora de escopo aqui.

É GET, não POST (diferente do padrão /gerar/ de outras ferramentas, que mutam estado ou recebem multipart) — decisão de uma rodada seguinte, corrigindo um bug real: a primeira versão era POST, e o frontend chamava via fetch + URL.createObjectURL(blob) + window.open(url) pra abrir numa nova aba (mesmo padrão de pidGerarArquivoPlanoSaude). Só que um documento carregado de uma URL blob: tem uma origem sintética — URLs relativas dentro do HTML (como as que {% static %} gera, ex. /static/img/logo-branco.png) não resolvem de forma confiável contra a origem real do Portal nesse contexto, e a logo do escritório no cabeçalho simplesmente não carregava. Com GET, o frontend abre a URL da API direto (window.open(/api/contabil-apuracoes/${id}/dashboard/, "_blank"), pidGerarDashboardContabil() em dashboard-contabil.js) — navegação de verdade, mesma origem, sem blob nenhum de permeio; {% static %} funciona igual a qualquer outra página do Portal. Também simplificou o JS (sem pidEnsureCsrfCookie/CSRF manual — GET não precisa).

Indicadores financeiros (indicadores.py)

Funções puras, mesmo espírito de regras.py — não tocam no ORM, recebem os dados já extraídos. Os grupos do Balancete são identificados por código de classificação fixo (CODIGO_*), calibrado contra o balancete de referência do usuário (792 - balancete 072026.pdf) — mesma decisão de risco já aceita em regra_saldo_negativo_caixa (código fixo "1.01.01.001" pro grupo Caixa). Se um cliente usar uma numeração de plano de contas diferente da vista até agora, os indicadores desse cliente saem errados silenciosamente — revisar contra mais balancetes reais de outras empresas antes de confiar cegamente no valor exibido ao cliente.

Códigos calibrados: Ativo Total "1", Ativo Circulante "1.01", Estoques "1.01.08", Imobilizado "1.02.05", Depreciação Acumulada "1.02.05.007", Passivo Total "2", Passivo Circulante "2.01", Patrimônio Líquido "2.04". O Passivo Não Circulante/Exigível a Longo Prazo não tem código calibrado — não aparece no balancete de referência, que não tem dívida de longo prazo — e é calculado por eliminação (Passivo Total − Passivo Circulante − Patrimônio Líquido), sempre exato pela identidade contábil, sem depender de adivinhar mais um código.

Validados byte a byte contra a captura de tela do Balancete anexada pelo usuário (Ativo Total 2.721.721,59 / Ativo Circulante 2.582.105,87 / Estoques 1.702.326,05 / Passivo Circulante 408.765,09 / Patrimônio Líquido 2.312.956,50 / Imobilizado 134.203,88 — bateram exatamente):

  • Liquidez Corrente = Ativo Circulante / Passivo Circulante → 6,32
  • Liquidez Seca = (Ativo Circulante − Estoques) / Passivo Circulante → 2,15
  • Composição do Endividamento = Passivo Circulante / Exigível Total → 100,00% (bate porque essa empresa não tem Passivo Não Circulante)
  • Grau de Endividamento = Exigível Total / Patrimônio Líquido → 17,67%
  • IPL (Imobilização do Patrimônio Líquido) = Imobilizado / Patrimônio Líquido → 5,80%

Aproximado, sem exemplo real pra validar: Liquidez Geral = Ativo Circulante / Exigível Total — trata o Realizável a Longo Prazo como indisponível/0, porque o Ativo Não Circulante ("1.02") hoje mistura Investimentos/Imobilizado com um eventual Realizável a Longo Prazo, sem separar (o parser não distingue isso). Coincide com a Liquidez Corrente quando a empresa não tem Passivo Não Circulante (era o caso do balancete de referência).

DRE: resultado_liquido é passado pra calcula_indicadores() já resolvido pela view (linhas_dre[-1].valor, a última linha na ordem do relatório) — não por texto, mais seguro. EBIT = Resultado Líquido − linha "(+/-) Despesas/Receitas Financeiras" (casada por substring via LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS em parser.py, não validada contra um PDF real como LINHA_DRE_RECEITA_LIQUIDA/LINHA_DRE_CUSTOS_TOTAIS já são). Depreciação/Amortização do mês = variação do saldo da conta de depreciação acumulada entre a apuração atual e a anterior da mesma empresa — indisponível na primeira apuração de uma empresa (mesmo espírito das regras 8-10 de auditoria, que também dependem de histórico); EBITDA = EBIT + essa variação, também indisponível quando ela for. ROA = Resultado Líquido / Ativo Total; ROE = Resultado Líquido / Patrimônio Líquido.

Kanitz (Termômetro de Insolvência) usa a fórmula-livro-texto padrão (FI = 0,05×ROE + 1,65×LiquidezGeral + 3,55×LiquidezSeca − 1,06×LiquidezCorrente − 0,33×GrauEndividamento), não validada contra o BI antigo que este Dashboard substitui — o valor mostrado numa captura do usuário ("11,31") está fora da faixa clássica do índice (−7 a +7), sugerindo que o BI antigo usa uma variação/escala diferente. O relatório marca esse card como estimativa; ajustar se o usuário trouxer a fórmula exata usada pelo BI antigo.

Todo indicador que pode ficar indisponível é Decimal | None no dataclass IndicadoresFinanceiros — None significa indisponível, nunca é tratado como zero (os filtros de template descritos abaixo respeitam essa distinção).

Template (templates/dashboard-contabil-relatorio.html) e filtros (portal_api/templatetags/contabil_extras.py)

Documento HTML autocontido — <style> inline com a mesma paleta já usada nos documentos gerados (indicadores/recibo.py: roxo #3d2178, dourado #b4872a), cabeçalho com static/img/logo-branco.png (texto branco — não logo.png, que ficaria ilegível sobre o banner roxo escuro, ver "Logos em static/img/" no CLAUDE.md raiz). Botão "Imprimir" (onclick="window.print()") escondido via @media print.

Conteúdo dividido em 3 abas (.dcr-tabs/.dcr-tab/[data-dcr-panel], JS puro inline no próprio template — não reaproveita .pa-tabs de perfis-acesso.css, que não é carregado neste documento autocontido), nesta ordem pedida pelo usuário: Balancete (aba inicial) → D.R.E. → Indicadores (os dois grupos de cards + o gráfico de evolução, que fazem mais sentido juntos). Na impressão (@media print), a barra de abas some e as 3 ficam visíveis ao mesmo tempo, cada uma numa página própria (page-break-after) — documento impresso não deve esconder conteúdo atrás de uma aba não clicada.

Observações espalhadas nas 3 abas, cada uma com o recorte certo (pedido explícito do usuário — antes ficavam todas juntas numa única seção fora das abas): a aba Balancete termina com "Observações do Balancete" (só observacoes_contas); a aba D.R.E. termina com "Observações da D.R.E." (só observacoes_dre); a aba Indicadores termina com "Todas as Observações da Análise" (observacoes_contas + observacoes_dre + achados_com_observacao juntos, cada item com um prefixo indicando a origem — "Balancete — ...", "D.R.E. — ...", "Auditoria — ..." — já que aqui não há mais uma aba própria pra inferir o contexto). Nunca "Achado"/"Achado de Auditoria" em texto visível — pedido explícito do usuário, mesmo motivo pelo qual a aba de revisão já se chama "Observações" (data-dc-tab="achados" com o texto "Observações", dashboard-contabil.html) e não "Achados"; achado/ContabilAchado/achados_com_observacao continuam normais como nome de variável/model/classe, só não podem aparecer como palavra na tela. As três seções reaproveitam o mesmo markup (.dcr-obs-lista/.dcr-obs-item), só filtrando quais das três listas do contexto (observacoes_contas/observacoes_dre/achados_com_observacao, já vindas prontas de views.py) cada uma itera — nenhuma mudança no backend foi necessária, é só reorganização do template. Estado vazio próprio por seção ("Nenhuma observação registrada no Balancete."/"...na D.R.E."/"...nesta análise.").

Balancete e D.R.E. têm árvore recolhível igual à tela de revisão (pedido explícito do usuário — a primeira versão do relatório vinha totalmente expandida, sem toggle). Diferença de arquitetura em relação a renderContas()/renderDre() em dashboard-contabil.js: lá é uma SPA que re-renderiza a tabela inteira a cada clique; aqui é HTML estático gerado uma vez, então o nível de cada linha e se ela "tem filhos" (nivel/tem_filhos) são calculados no servidor (_contabil_arvore_contexto() em views.py, mesmo algoritmo — "tem filhos" = a próxima linha tem nível maior) e ficam como atributos data-dcr-nivel/data-dcr-tem-filhos/data-dcr-id em cada <tr> já renderizada. pidDcrArvore(tbodyId) (JS inline no template) só alterna o atributo hidden das <tr> existentes com a mesma lógica de pilha de níveis recolhidos, sem reconstruir HTML nenhum. Na impressão, .dcr-tabela tbody tr[hidden] { display: table-row !important; } força toda linha a aparecer mesmo que o usuário tenha recolhido algum grupo na tela — documento impresso não deve esconder conta nenhuma atrás de um grupo recolhido.

Nasce recolhido a partir do "grupo 4", mesmo limiar da tela de revisão (rodada seguinte, mesmo pedido — ver "Nasce recolhida a partir do 'grupo 4'" acima): _contabil_arvore_contexto() ganhou um terceiro campo por item, colapsado_padrao (tem_filhos and nivel >= _CONTABIL_NIVEL_ABERTO_PADRAO, constante módulo-level = 3), viram data-dcr-colapsado-padrao="1"/"0" em cada <tr>; o botão de toggle só ganha a classe is-expanded inicial quando not item.colapsado_padrao. pidDcrArvore() lê esse atributo antes do primeiro clique e semeia o objeto colapsadas (antes só populado por interação do usuário) com os ids marcados, chamando atualiza() uma vez na inicialização — o resto do algoritmo (pilha de níveis, alternar hidden) não mudou. Mesma constante conceitual dos dois lados (_CONTABIL_NIVEL_ABERTO_PADRAO em views.py / PID_DC_NIVEL_ABERTO_PADRAO em dashboard-contabil.js), duplicada porque um é Python renderizado uma vez e o outro é JS de uma SPA — se o limiar mudar, ajustar os dois.

Gráfico de evolução do Resultado Líquido via Chart.js (<script src="https://cdn.jsdelivr.net/npm/chart.js@4">, snippet oficial recomendado pela biblioteca) — série injetada com {{ evolucao|json_script:"dcr-evolucao-data" }} (usa DjangoJSONEncoder, que já serializa Decimal/date sem precisar de nenhum encoder customizado) e lida em JS puro. Inicializado sob demanda, não no carregamento da página: o <canvas> nasce dentro da aba "Indicadores", escondida por padrão (Balancete é a aba inicial), e Chart.js não desenha corretamente num canvas com largura/altura zero — inicializaGrafico() só roda no primeiro clique nessa aba (ou no evento beforeprint, pra garantir que o gráfico exista na versão impressa mesmo que a aba nunca tenha sido aberta na tela).

portal_api/templatetags/contabil_extras.py — primeiro uso de template tags customizadas no projeto (precisou de portal_api/templatetags/__init__.py, auto-descoberto pelo Django por portal_api já estar em INSTALLED_APPS). Filtros moeda/percentual/indice/competencia, todos no padrão brasileiro (separador de milhar ., decimal ,) — mesmo espírito do helper _moeda() que já existe, duplicado por arquivo, nos dois geradores de PDF (indicadores/recibo.py, custo_contratacao/pdf.py), mas como filtro reaproveitável, já que este template tem tabelas inteiras de valores monetários (Balancete/DRE), não um valor por vez. None sempre vira "—", nunca "R$ 0,00"/"0,00%" — ver acima por quê. Um quinto filtro, numero_bruto, existe só pra alimentar a animação de contagem dos cards (ver "Visual" abaixo) — devolve o valor cru (str(float(valor)), "" se None) exclusivamente para um atributo data-count, nunca pro texto exibido.

Visual: fontes, animações e contagem animada dos cards

Pedido explícito do usuário — "deixar mais bonito, complexo, dinâmico, com animações fluidas". Fontes "Manrope" (títulos/cards/abas) + "Inter" (corpo, font-variant-numeric: tabular-nums nas colunas de valor) via Google Fonts. Paleta estendida da mesma família roxo/dourado já usada nos documentos gerados, com gradiente + glow radial sutil no cabeçalho (.dcr-header::before, @keyframes dcrGlow) e no fundo da página.

Regra de ouro pra 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 já basta pra devolver o elemento ao estado normal (visível, posição natural) na impressão, sem precisar de um reset explícito por seletor — se alguma animação nova for adicionada aqui, seguir essa mesma disciplina, senão a impressão pode sair com conteúdo em branco. O restante do @media print já existente (abas somem, todas as 3 aparecem juntas, linhas recolhidas forçadas a aparecer) continua igual.

Cards ganharam data-count/data-final/data-format/data-color-rule (ver pidDcrAnimaContadores()): a contagem anima de 0 até o valor com requestAnimationFrame/easing, formatando os quadros intermediários com Intl/toLocaleString("pt-BR", ...) nativo do navegador — mas o texto final escrito ao fim da animação é sempre data-final, a mesma string que os filtros Django já geraram (nunca um valor recalculado em JS, só decoração da transição). Cor por sinal/threshold só onde é seguro sem inventar limiar nenhum: data-color-rule="sign" (ROA/ROE/EBIT/EBITDA — positivo verde, negativo vermelho, convenção universal) e data-color-rule="liquidez" (Liquidez Corrente/Seca/Geral — verde se ≥ 1, âmbar/vermelho se < 1, também convenção padrão de mercado). Kanitz, Composição/Grau de Endividamento e IPL não ganham cor nenhuma — não existe um limiar validado pra eles nesta implementação (ver "Fórmulas" acima), então colorir feito "bom"/"ruim" daria uma falsa segurança num número que o próprio card já avisa ser estimativa/aproximação.

Cards e gráfico só existem/animam depois da aba "Indicadores" ser aberta pela primeira vez (mesmo motivo do gráfico: canvas com tamanho zero não desenha certo, e contar um número invisível não faz sentido). Isso criou um risco real de impressão: se o usuário nunca abrir essa aba e mandar imprimir direto, a contagem começaria do zero bem na hora que o navegador captura a página. pidDcrAnimaContadores(true) (parâmetro instantaneo) resolve isso — o único listener de beforeprint do documento decide entre inicializar tudo já no valor final (se a aba nunca foi aberta) ou só finalizar uma contagem já em andamento (finalizadores, um array de callbacks que força cada card pro texto final) — nunca as duas coisas competindo (era um bug real de uma versão intermediária desta mesma rodada, corrigido antes do usuário testar: dois listeners de beforeprint separados podiam disparar uma animação nova bem na hora de imprimir, sem tempo de terminar).

Abas ganharam um indicador deslizante (.dcr-tabs__indicator, getBoundingClientRect-like via offsetLeft/offsetWidth, recalculado no resize e no load — texto de fonte customizada pode mudar a largura da aba depois do primeiro paint). Árvore recolhível do Balancete/D.R.E. ganhou um fade rápido só nas linhas que acabaram de aparecer (.dcr-row-in, reflow forçado via void tr.offsetWidth pra poder reiniciar a animação a cada clique), não a tabela inteira — evita flicker num clique que só afeta um grupo pequeno.

Frontend (static/js/dashboard-contabil.js)

pidGerarDashboardContabil(id) é só window.open(/api/contabil-apuracoes/${id}/dashboard/, "_blank") — navegação direta, sem fetch/blob/CSRF nenhum (ver por que isso importa em "É GET, não POST" acima). Bem mais simples do que o padrão de pidGerarArquivoPlanoSaude (importacao-plano-saude.js), que precisa de fetch manual porque /gerar/ ali é POST e devolve um arquivo pra download, não uma página pra navegar.

Exportação em XLSX (exportacao.py)

Pedido explícito do usuário: um botão "Exportar XLSX" dentro do relatório "Gerar Dashboard", um por seção (Balancete/D.R.E.), pra baixar aquela tabela em planilha. ContabilApuracaoViewSet.exportar_xlsx() (GET /api/contabil-apuracoes/{id}/exportar-xlsx/?parte=balancete ou ?parte=dre, mesma permissão de toggle único das outras actions) monta as linhas já com o nível de indentação calculado (mesma fórmula de dashboard(): conta.codigo.count(".") pro Balancete, max(0, linha.nivel) pra DRE) e chama dashboard_contabil.exportacao.gera_xlsx_balancete()/gera_xlsx_dre() — funções puras (openpyxl, sem tocar no ORM, mesmo espírito de indicadores.py/regras.py) que recebem dataclasses (LinhaBalanceteXlsx/LinhaDreXlsx) já prontas, não os models do Django.

Cada planilha nasce com cabeçalho (título/empresa/CNPJ/competência, linhas 1-3, mescladas), uma linha de cabeçalho de colunas com fundo roxo (#3d2178, mesma paleta dos outros documentos gerados pelo escritório — indicadores/recibo.py/custo_contratacao/pdf.py, nunca a marca "P.I.D." do Portal) e a tabela de dados a partir da linha 6, com freeze_panes logo abaixo do cabeçalho de colunas. Conta sintética (Balancete, tipo="S") e linha totalizadora (DRE, totalizador=True) ganham negrito + um fundo dourado claro (#f6ecd4), mesmo destaque visual que essas linhas já têm na tela de revisão e no relatório HTML. A hierarquia (nível de indentação) vira Alignment(indent=nivel) na célula de descrição — não dá pra reproduzir o toggle recolher/expandir de uma planilha, então a árvore sempre nasce "totalmente expandida" (mesmo espírito do relatório HTML impresso). Colunas monetárias usam number_format = '"R$" #,##0.00' (valor gravado como float, não como texto formatado — continua editável/somável no Excel).

Botão "Exportar XLSX" (.dcr-export-btn, dashboard-contabil-relatorio.html) é um <a href="/api/contabil-apuracoes/{{ apuracao.id }}/exportar-xlsx/?parte=..."> puro — sem JS nenhum, mesmo espírito de link direto de download; o browser já lida com o Content-Disposition: attachment da resposta. Escondido em @media print junto do botão "Imprimir" (.dcr-print-btn), já que exportar não faz sentido numa versão impressa.

Aba "Dashboard" na tela de revisão — ocultar cards/observações do relatório antes de gerar

Pedido explícito do usuário: uma 4ª aba na tela de revisão (dashboard-contabil.html, depois de Observações/Balancete/DRE, data-dc-tab="dashboard") mostrando antes de gerar o relatório os mesmos 11 cards de indicador e a mesma lista de observações (contas + linhas de DRE + achados com observacao_contador) que vão pro relatório "Gerar Dashboard", com um botão de olho em cada item pra escondê-lo do relatório final — sem apagar o dado em si (a conta/linha/achado continua normal no Balancete/DRE/Observações da revisão, só não entra na versão que vai pro administrador da empresa).

Modelos (portal_api/models.py): ContabilConta.oculta_no_relatorio/ContabilLinhaDre.oculta_no_relatorio/ContabilAchado.oculto_no_relatorio (BooleanField, default False — afeta só a seção "Observações" do relatório, nunca a linha em si no Balancete/DRE/Observações da revisão) e ContabilApuracao.indicadores_ocultos (JSONField, default list — lista de chaves de IndicadoresFinanceiros, ex. ["kanitz", "ipl"]). Migração 0059_contabilachado_oculto_no_relatorio_and_more.

Chaves válidas de indicador (dashboard_contabil/indicadores.py, CHAVES_CARDS/CHAVE_CARDS_RESULTADO/CHAVE_CARDS_LIQUIDEZ): as 11 chaves que viram card no relatório (roa/roe/kanitz/ebit/ebitda/liquidez_corrente/liquidez_seca/liquidez_geral/composicao_endividamento/grau_endividamento/ipl) — subconjunto dos ~20 campos de IndicadoresFinanceiros (os demais, ex. ativo_total/estoques, só alimentam fórmulas, nunca tiveram card próprio). Usada tanto pra validar o corpo de indicadores_ocultos() (ContabilIndicadoresOcultosSerializer, ChoiceField por chave) quanto pelos dois grupos do relatório.

Endpoints novos (views.py):

  • GET /api/contabil-apuracoes/{id}/indicadores/ (ContabilApuracaoViewSet.indicadores()) — {indicadores: {...}, indicadores_ocultos: [...]}; indicadores é dataclasses.asdict(IndicadoresFinanceiros) puro (não passa por um Serializer — os Decimal/None já chegam certos no JSON porque o JSONRenderer do DRF aplica seu JSONEncoder recursivamente em qualquer estrutura de resposta, não só em campo de Serializer). Reaproveita o cálculo de dashboard() via o helper novo _contabil_calcula_indicadores(apuracao) (extraído do que antes estava só dentro de dashboard()) — mesma fórmula, duas telas (pré-visualização + relatório final).
  • POST /api/contabil-apuracoes/{id}/indicadores-ocultos/ (.indicadores_ocultos()) — substitui a lista inteira de chaves ocultas de uma vez (ContabilIndicadoresOcultosSerializer, corpo {indicadores_ocultos: [...]}) — o frontend já tem a lista atual (via GET acima), só alterna uma chave e reenvia tudo; mais simples que um endpoint de toggle por chave. Gate _contabil_garante_em_revisao(), mesmo de qualquer edição de observação/achado.
  • POST /api/contabil-achados/{id}/alternar-oculto/ (ContabilAchadoViewSet.alternar_oculto()) — não reaproveita update()/ContabilAchadoAjusteSerializer (que exige status + observacao_contador não-vazia pra "tratar"/"ignorar"): ocultar do relatório é uma decisão independente de tratativa, um achado pode continuar "Pendente" e mesmo assim ter sua observação escondida caso um dia venha a ser preenchida sem mudar o status. Serializer próprio (ContabilAchadoOcultoSerializer, só {oculto_no_relatorio: bool}), gate igual. http_method_names do viewset ganhou "post" só por causa desta action (GET/PATCH continuam cobrindo o resto).
  • ContabilConta/ContabilLinhaDre não precisaram de action nova — oculta_no_relatorio só entrou nos fields de ContabilContaSerializer/ContabilLinhaDreSerializer (ao lado de observacao, já gravável) e o PATCH genérico que essas duas telas já expõem (ContabilContaViewSet/ContabilLinhaDreViewSet, sem "Ajuste" nenhum no meio) aceita o campo isolado, sem exigir os demais.

dashboard() filtra pelo que está oculto (views.py): observacoes_contas/observacoes_dre/achados_com_observacao no contexto do relatório ganharam and not X.oculta_no_relatorio/oculto_no_relatorio; indicadores_ocultos (a lista crua) e dois booleanos por grupo (indicadores_grupo_resultado_visivel/indicadores_grupo_liquidez_visivel, any(chave not in ocultos ...)) entram no contexto pro template. dashboard-contabil-relatorio.html envolve cada um dos 11 .dcr-card num {% if "chave" not in indicadores_ocultos %} (o operador in/not in do Django Template Language já faz teste de pertencimento numa lista direto, sem precisar de filtro customizado novo) e cada um dos 2 <h2>+.dcr-cards de grupo num {% if indicadores_grupo_*_visivel %} — evita um cabeçalho de seção "Indicadores de Resultado" sobrando sozinho, sem nenhum card embaixo, se o contador ocultar os 5 de uma vez. O gráfico de evolução do Resultado Líquido e a exportação XLSX de Balancete/DRE não são afetados — não são "cards de indicador" nem "observações", ficaram fora do escopo desta rodada.

Frontend (dashboard-contabil.js): dcIndicadoresAtual ({indicadores, indicadores_ocultos, metadados}, ver "Banco de indicadores personalizados" abaixo pro terceiro campo — ou null) é buscado sob demanda só na primeira vez que a aba "Dashboard" é aberta depois de abrir/criar a apuração (renderDashboardTab(), chamado pelo handler de #dc-tabs; resetado pra null em renderRevisao() e após qualquer criação/edição/exclusão de indicador personalizado) — evita um cálculo/consulta extra ao histórico completo da empresa (_contabil_monta_historico_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 direto de apuracaoAtual.contas/.linhas_dre/.achados, já carregados no payload principal da apuração. Cada card/linha tem um botão de olho (.dc-dash-toggle-btn, reaproveita .icon-btn de components.css + ícone SVG de olho aberto/fechado inline, sem depender de pid-icone-escuro.svg) que chama a action correspondente e atualiza só o item local (sem re-buscar a apuração inteira); desabilitado (disabled) quando a apuração já está concluida, mesmo espírito de "não pode mais editar observações/achados" já aplicado ao textarea/botões dos outros dois modais desta ferramenta. pidDcFormatIndicadorMoeda/Percentual/Indice espelham os filtros moeda/percentual/indice de contabil_extras.py só que em JS — null/undefined sempre vira "—", nunca "R$ 0,00" (mesmo cuidado do relatório: em IndicadoresFinanceiros, None é "indisponível", ex. EBITDA sem apuração anterior, não zero) — não reaproveita pidDcFormatMoeda() já existente no arquivo, que trata null como 0 de propósito (usado só pra valores de conta/DRE, que nunca são None).

Banco de indicadores personalizados — fórmulas, componentes e a aba "Dashboard"

Pedido explícito do usuário, rodada seguinte à aba "Dashboard" acima: cada card de indicador devia ser "um indicador efetivo calculado a partir das contas contábeis" — o contador poder ver a fórmula de cada um (inclusive os 11 de sistema) e criar indicador novo, escolhendo contas do Balancete/linhas da DRE/variação entre apurações/outros indicadores já existentes como componentes da fórmula. Escopo alinhado por AskUserQuestion antes de implementar: (a) os 11 indicadores de sistema não foram migrados pra este banco nesta rodada — continuam com o cálculo Python fixo de sempre em calcula_indicadores(), risco zero de mudar silenciosamente um valor já calibrado contra balancete real; só ganharam metadados de exibição (descrição/fórmula em texto) pro botão "Ver fórmula"; (b) a fórmula de um indicador personalizado é uma expressão de verdade (não só "A ÷ B"), avaliada por um interpretador restrito, não um eval(); (c) "selecionar conta ou grupo de contas" significa marcar uma ou mais contas específicas do Balancete (checklist), não digitar um prefixo de código.

Modelos (portal_api/models.py, migração 0060_indicadorcontabildefinicao_and_more):

  • IndicadorContabilDefinicao — chave (SlugField único, sempre derivada do nome na criação, nunca aceita do cliente — mesmo espírito de RegraCusteioPlanoSaude.nome, ver _gera_chave_indicador_contabil() em views.py; imutável depois, já que pode estar referenciada em ContabilApuracao.indicadores_ocultos ou na fórmula de outro indicador), nome, descricao (texto simples, sem rich-text/imagens como AjudaAplicacao — não pedido, indicador é um card curto), formula (CharField, a expressão), formato (moeda/percentual/indice, mesmos 3 formatos dos indicadores de sistema), criado_por/criado_em/atualizado_em.
  • IndicadorContabilComponente (related_name="componentes") — uma peça da fórmula, chave (identificador Python válido — ^[a-z][a-z0-9_]*$, RegexValidator, não um SlugField comum porque vira nome de variável dentro da árvore ast do avaliador; diferente da chave da própria Definicao, que pode ter hífen à vontade) + tipo (contas/linha_dre/variacao_conta/indicador) + os 3 campos de referência, só um preenchido por vez conforme o tipo: contas_codigos (lista de ContabilConta.codigo, usado por contas/variacao_conta), linhas_dre_descricoes (lista de ContabilLinhaDre.descricao exata, usado por linha_dre), indicador_referenciado (chave de outro indicador — de sistema ou personalizado, usado por indicador). 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 que o relatório for gerado; funciona quando a empresa usa o mesmo plano de contas, sai errado silenciosamente se não (mesmo risco já aceito pelos códigos fixos de calcula_indicadores()).

Motor de fórmula (dashboard_contabil/formula.py, função pura, mesmo espírito de regras.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, chave → Decimal | None); qualquer outro nó (chamada de função, atributo, import, comparação...) levanta FormulaInvalidaError antes de qualquer coisa ser executada — nunca eval()/compile() puro sobre um texto digitado pelo contador. Se qualquer nome referenciado valer None em qualquer ponto da árvore, ou a fórmula dividir por zero, o resultado inteiro é None — "indisponível" nunca vira 0, mesma disciplina de IndicadoresFinanceiros. valida_formula(expressao, chaves_disponiveis) roda a mesma árvore com valores fictícios (1) só pra validar sintaxe/nomes na hora de salvar a definição, sem se importar com o resultado numérico.

Resolução de um indicador personalizado contra uma apuração (views.py):

  • _ContabilDadosIndicadores (dataclass) + _contabil_coleta_dados_indicadores(apuracao) — junta contas_atuais/dre_atual/resultado_liquido/historico_completo (mesma query que já existia) e deriva contas_anteriores (historico_completo[0].contas se houver — só a apuração anterior imediata, mesma convenção de "Depreciação/Amortização do mês" em calcula_indicadores()). Reaproveitada tanto por _contabil_calcula_indicadores() (os 11 de sistema, sem mudança de comportamento, só recebe o dataclass em vez de recalcular tudo) quanto pelos personalizados abaixo — evita duas idas ao banco pelos mesmos dados quando dashboard()/indicadores() (que pedem os dois cálculos juntos) rodam.
  • _contabil_resolve_componente_personalizado(componente, dados, valores) — resolve um componente pro valor que entra na fórmula. contas/variacao_conta somam em valor absoluto (mesma convenção de indicadores._saldo(): 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 (ambas em valor absoluto), None se não houver apuração anterior; indicador só faz valores.get(chave_referenciada).
  • _contabil_calcula_indicadores_personalizados(dados, valores_sistema) — resolve todas as definições cadastradas de uma vez. valores_sistema já chega só com as 11 chaves de CHAVES_CARDS (o que um componente tipo="indicador" pode referenciar de sistema — ver validação no serializer). Iterativo, não uma ordem topológica "de verdade": a cada rodada, calcula todo indicador cujos componentes tipo="indicador" já têm valor conhecido (de sistema, ou personalizado já resolvido numa rodada anterior), repete até não sobrar progresso — cobre encadeamento entre indicadores personalizados (um referenciando o outro) sem precisar ordenar por dependência explicitamente. Um indicador cuja dependência nunca resolve (referência quebrada, ou ciclo entre dois personalizados — ex. A referencia B e B referencia A) fica com valor None pra sempre, sem lançar erro — uma fórmula mal configurada não pode derrubar o cálculo do relatório inteiro. Validado com um teste manual (rollback, sem persistir nada) cobrindo encadeamento de 2 níveis, referência a indicador de sistema e um ciclo de 2 — os três se comportam como descrito.
  • _contabil_formata_indicador(valor, formato) — só pros personalizados: o Django Template Language não permite escolher um filtro (moeda/percentual/indice) por nome vindo de uma variável, então o valor de um indicador personalizado chega já formatado como texto no contexto do relatório (dashboard()), reaproveitando as mesmas 3 funções de contabil_extras.py chamadas direto (um filtro de template continua sendo uma função Python comum, só registrada).

Endpoints novos (views.py/urls.py, IndicadorContabilDefinicaoViewSet, router.register("contabil-indicadores-definicoes", ...)) — mesma permissão de toggle único do resto da ferramenta (PermissaoApp("relatorios", "dashboard-contabil")): qualquer contador com acesso já pode criar/editar/excluir indicador personalizado, não é uma tela administrativa restrita ao perfil "Inovação" (diferente do padrão de "Mais informações"/AjudaAplicacao, decisão deliberada — quem cria os indicadores aqui é o próprio contador usando a ferramenta, não a Integração e Inovação).

  • POST/PATCH /api/contabil-indicadores-definicoes/ (create()/partial_update()) — corpo sempre o indicador inteiro (IndicadorContabilDefinicaoInputSerializer: nome/descrição/formula/formato/componentes[]), mesmo em PATCH — um indicador personalizado é pequeno o bastante (poucos componentes) pra não valer a pena editar incrementalmente; _salva_componentes() sempre substitui todos os componentes de uma vez (delete() + bulk_create()), nunca faz diff. Validação em duas camadas: validate_componentes() confere chave única por componente + campo de referência preenchido conforme o tipo + indicador_referenciado existente (CHAVES_CARDS ∪ chaves de outras definições, excluindo a própria ao editar); validate() chama formula.valida_formula() contra o conjunto de chaves dos componentes.
  • DELETE /api/contabil-indicadores-definicoes/{id}/ (perform_destroy()) — bloqueia (ValidationError) se outra definição referencia esta pela fórmula (tipo="indicador"), listando os nomes dependentes — evitar deixar uma fórmula alheia quebrada silenciosamente. Depois de excluir, também limpa a chave de toda ContabilApuracao.indicadores_selecionados/indicadores_ocultos que a referenciava (bug real, rodada 106: sem essa limpeza a chave ficava órfã, e como ContabilIndicadoresSelecionadosSerializer/ContabilIndicadoresOcultosSerializer reenviam/validam a lista inteira a cada alternância de checkbox no hub, o usuário ficava travado sem conseguir alternar nenhum indicador na apuração afetada — não só o excluído). As duas validações também passaram a descartar chave inexistente silenciosamente em vez de rejeitar a lista inteira (rede de segurança pra referência órfã que já existia antes desta limpeza, já que é só estado de exibição, não dado auditado).
  • GET /api/contabil-apuracoes/{id}/indicadores/ (ContabilApuracaoViewSet.indicadores(), estendido) — indicadores agora é {**valores_sistema_cards, **valores_personalizados} (as 11 chaves de sistema mais toda chave personalizada); ganhou metadados — dict chave → {nome, grupo, formato, descricao, formula_texto, personalizado, definicao_id}, uniforme pra indicador de sistema (grupo/formato/descricao/formula_texto vêm de METADADOS_CARDS, personalizado=False, definicao_id=None) ou personalizado (grupo sempre "Indicadores Personalizados", resto vem da própria IndicadorContabilDefinicao, personalizado=True) — é o que alimenta o botão "Ver fórmula" e a decisão de que grupo cada card cai no frontend, sem precisar de uma segunda fonte de verdade lá.
  • dashboard() (relatório) ganhou indicadores_personalizados no contexto — lista pronta ({chave, nome, valor_formatado}, já filtrando indicadores_ocultos e já com o valor formatado via _contabil_formata_indicador) pro {% for %} genérico do template (ver abaixo), diferente dos 11 de sistema que continuam com um .dcr-card hardcoded cada.

Metadados dos 11 indicadores de sistema (dashboard_contabil/indicadores.py, MetaIndicador/METADADOS_CARDS) — textos-base fornecidos pelo usuário (glossário de KPI já em uso pelo escritório), ajustados em dois pontos pra bater com o que o código realmente calcula, não copiados ao pé da letra:

  • Grau de Endividamento e IPL: o texto original dizia "÷ (Patrimônio Líquido × 100)" — alinhado por pergunta que isso é só a forma de dizer "o resultado vira %", não uma divisão a mais; calcula_indicadores() já fazia (e continua fazendo) só A ÷ B, exibido com o filtro percentual (que multiplica por 100 pra exibição). formula_texto desses dois reflete o cálculo real (... ÷ Patrimônio Líquido, exibido em %), não o texto literal do usuário — mostrar "÷100" enganaria o contador que olhar o resultado e não bater a conta.
  • EBIT: o texto original ("Receita Líquida − Custos − Despesas Operacionais", a definição-livro-texto "de cima pra baixo") não é como o código calcula (resultado_liquido − despesas_receitas_financeiras, "de baixo pra cima", partindo do Lucro Líquido já apurado) — formula_texto documenta o que o código realmente faz, não a definição conceitual. Os dois caminhos tendem a convergir num DRE bem formado, mas não foram provados matematicamente equivalentes; se um dia esse EBIT for migrado pro banco de indicadores (fora de escopo desta rodada, ver acima), vale revisitar qual das duas fórmulas usar.
  • Os demais 9 batem com o texto do usuário sem ajuste.

Frontend (dashboard-contabil.js/.html/.css) — PID_DC_DASH_GRUPOS_INDICADORES/PID_DC_DASH_INDICADOR_INFO (constantes hardcoded da rodada anterior) foram removidas: renderDashboardIndicadores() agora agrupa as chaves de dcIndicadoresAtual.metadados pelo campo .grupo de cada uma (vem do servidor), só a ORDEM de exibição dos grupos continua fixa no frontend (PID_DC_DASH_ORDEM_GRUPOS, grupo desconhecido cai no fim) — único jeito de "Indicadores Personalizados" aparecer sem precisar hardcodar nada de novo aqui a cada indicador criado.

  • Card é clicável (fora dos botões) → abre #dc-indicador-formula-modal (pidDcAbrirFormulaModal()) mostrando nome/descrição/formula_texto de metadados[chave] — funciona igual pra indicador de sistema ou personalizado, já que os dois têm a mesma forma de metadado.
  • Card de indicador personalizado ganha um botão extra (lápis, data-dc-dash-indicador-editar) ao lado do olho — busca a definição completa (GET /contabil-indicadores-definicoes/{id}/, precisa dos componentes que metadados não carrega) e abre #dc-indicador-modal prefiltrada pra edição; indicador de sistema não tem esse botão (meta.personalizado === false).
  • Modal "Novo/Editar Indicador" (#dc-indicador-modal, .modal-card--wide): nome/descrição/formato/fórmula + uma lista dinâmica de componentes (#dc-indicador-componentes, pidDcCriaLinhaComponente()/pidDcRenderComponentePicker()). Cada linha tem chave+tipo (sempre presentes) e um "picker" que muda conforme o tipo escolhido: contas/variacao_conta e linha_dre reaproveitam .checklist-box/.checklist-item/.checklist-search de components.css (mesmo componente visual já usado nos checklists de Perfis/Departamento em usuarios.html) — necessário porque uma apuração real tem ~100-150 contas/linhas de DRE, sem busca a lista seria inutilizável; indicador vira um <select> das chaves de dcIndicadoresAtual.metadados (exclui a própria chave, se estiver editando). O picker de cada linha é reconstruído (pidDcRenderComponentePicker) só quando o <select> de tipo muda (não a cada tecla digitada na busca, que só filtra via hidden nos itens já renderizados) — troca de tipo descarta a seleção anterior daquele componente, mesmo espírito de "começar do zero" ao mudar de tipo.
  • Componente "órfão" ao editar de uma apuração diferente da original: os pickers de contas/linha_dre são montados a partir de apuracaoAtual (a que está sendo revisada agora), mas um componente pode ter sido configurado a partir de outra apuração (outra empresa/competência) — se um código/descrição salvo não existe na apuração atual, ele não apareceria na lista pra ser marcado, e salvar sem tocar naquele componente apagaria silenciosamente essa referência (bug real, pego antes do usuário testar). Corrigido: todo código/descrição selecionado que não existe na apuração atual entra como um item extra no topo do checklist, já marcado e com uma nota "não encontrada nesta apuração, mantida" — continua salvo de volta do jeito que estava, a menos que o contador desmarque de propósito.
  • Ao salvar, sempre reenvia componentes inteiro (não um diff) — reflete o "substitui tudo" do backend (_salva_componentes()); ao concluir (criar/editar/excluir), dcIndicadoresAtual = null força renderDashboardTab() buscar tudo de novo (valores recalculados, metadados atualizados).
  • Botão "Excluir" só aparece editando (#dc-indicador-modal-excluir-btn, hidden na criação) — usa pidConfirm({perigoso: true}), nunca window.confirm (ver [[feedback_popups_no_padrao_do_portal]] na memória).

Indicador padrão vs. não padrão + "Gerenciar Indicadores" + modal com scroll

Pedido explícito do usuário, rodada seguinte: (a) o modal "Novo/Editar Indicador" não tinha scroll — com vários componentes, o conteúdo crescia pra fora da tela e só dava pra alcançar "Salvar" dando zoom out no navegador; (b) precisava de um jeito de ver e editar qualquer indicador já criado, não só os que já estão aparecendo nesta apuração; (c) indicador personalizado precisava poder ser padrão (aparece automaticamente em toda apuração, comportamento que já existia) ou não padrão (fica salvo/editável, mas só aparece numa apuração específica quando o contador seleciona ali).

Modal com scroll (components.css): .modal-card ganhou max-height: calc(100vh - var(--space-5) * 2) + overflow-y: auto — mudança global (toda tela que usa .modal-card), sem efeito em modal que já cabia na tela, e a rede de segurança que faltava pra qualquer modal futuro com conteúdo dinâmico. Adicionalmente, só a lista de componentes (#dc-indicador-componentes, classe .dc-ind-componentes-lista, dashboard-contabil.css) tem seu próprio max-height:320px; overflow-y:auto — rola só ela, não o modal inteiro, então nome/descrição/formato (cabeçalho) e fórmula/ações (rodapé) continuam alcançáveis sem precisar rolar duas vezes.

Modelos: IndicadorContabilDefinicao.padrao (BooleanField, default True — indicador já existente antes desta rodada continua aparecendo em toda apuração, sem regressão) e ContabilApuracao.indicadores_selecionados (JSONField, default list — chaves de indicador não padrão ativadas especificamente nesta apuração; um indicador padrão nunca precisa aparecer aqui). Migração 0061_contabilapuracao_indicadores_selecionados_and_more.

Cálculo (views.py, _contabil_calcula_indicadores_personalizados()): ganhou o parâmetro indicadores_selecionados — filtra IndicadorContabilDefinicao.objects.all() logo no início pra só os que têm padrao=True ou chave in indicadores_selecionados; o resto do algoritmo (resolução iterativa, ciclo vira None) não mudou. dashboard()/indicadores() passam apuracao.indicadores_selecionados adiante e também filtram por esse mesmo critério ao montar indicadores_personalizados_cards/metadados — um indicador não padrão não selecionado não aparece em lugar nenhum desta apuração (nem no relatório, nem na aba "Dashboard"), mas continua existindo/editável via a listagem completa (GET /api/contabil-indicadores-definicoes/, usada pelo hub — nunca filtrada por apuração, sempre lista tudo).

Endpoint novo: POST /api/contabil-apuracoes/{id}/indicadores-selecionados/ (ContabilApuracaoViewSet.indicadores_selecionados()) — mesmo padrão de indicadores_ocultos() (substitui a lista inteira de uma vez), ContabilIndicadoresSelecionadosSerializer valida que toda chave enviada corresponde a uma definição com padrao=False (selecionar uma chave padrão não faz sentido, ela já aparece sempre — rejeitado com 400). IndicadorContabilDefinicaoInputSerializer ganhou padrao (opcional, default True) — create()/partial_update() gravam o campo.

Frontend — "Gerenciar Indicadores" (dashboard-contabil.js/.html): o botão que antes abria "Novo Indicador" direto virou #dc-indicadores-gerenciar-btn, abrindo um modal-hub novo (#dc-indicadores-hub-modal) com duas listas — "Padrão" e "Não padrão" (GET /contabil-indicadores-definicoes/, sempre a lista completa, sem recorte por apuração). Cada linha (pidDcCriaLinhaHub()): nome (clicável, abre "Ver fórmula"), botão de editar (lápis, abre #dc-indicador-modal — o mesmo form de sempre, agora empilhado por cima do hub via .modal-overlay--top, reaproveitando o mecanismo já usado por confirm-modal.js) e, só nas linhas "Não padrão", um checkbox "ativo nesta apuração" (data-dc-hub-toggle, chama indicadores-selecionados/). O botão "+ Novo Indicador" fica dentro do hub agora, não solto na aba — reflete o pedido do usuário ("selecionados... quando acessar o botão de novo indicador").

  • pidDcAbrirFormulaModal() deixou de receber uma chave (só resolvia contra dcIndicadoresAtual.metadados, que só lista indicador ativo nesta apuração) e passou a receber um objeto {nome, descricao, formula_texto} direto — necessário pro hub poder mostrar a fórmula de um indicador não padrão ainda não selecionado (que não está em metadados), buscando a definição completa (GET .../{id}/) na hora do clique.
  • Depois de criar/editar/excluir um indicador (pidDcAposSalvarOuExcluirIndicador()), além de recarregar os cards da aba "Dashboard" (dcIndicadoresAtual = null + renderDashboardTab()), também re-renderiza o hub se ele estiver aberto por trás — sem isso a lista do hub ficaria desatualizada até fechar e abrir de novo.

Migração dos 11 indicadores de sistema pro banco (rodada seguinte)

Pedido explícito do usuário — reversão deliberada da decisão de escopo da rodada anterior ("Banco de indicadores personalizados"), que tinha deixado os 11 indicadores "de sistema" de fora do banco de propósito, pelo risco de mapear uma conta errado e mudar silenciosamente um valor já calibrado. Confirmado por AskUserQuestion (com o risco explicado antes) que o usuário queria a fórmula de verdade editável, não só o texto de exibição — então os 11 (roa/roe/kanitz/ebit/ebitda/liquidez_corrente/liquidez_seca/liquidez_geral/composicao_endividamento/grau_endividamento/ipl) viraram IndicadorContabilDefinicao de verdade, com as mesmas chaves de antes (preserva qualquer indicadores_ocultos já salvo). Não são mais um caso especial em lugar nenhum do código — CRUD, cálculo, exibição, tudo passa pelo mesmo caminho de qualquer indicador personalizado.

Novo tipo de componente: resultado_liquido (IndicadorContabilComponente.TIPO_RESULTADO_LIQUIDO, sem nenhum campo de referência — nem contas_codigos, nem linhas_dre_descricoes, nem indicador_referenciado) — resolve sempre pra dados.resultado_liquido (a última linha da DRE, por posição, não por texto). Necessário porque ROA/ROE/EBIT precisam do resultado líquido do período, e a última linha da DRE muda de rótulo conforme o sinal do resultado — confirmado contra um balancete real (792 - balancete 072026.pdf... a mesma apuração de referência já usada em regras.py): uma empresa com prejuízo termina em "(=) PREJUÍZO LÍQUIDO DO EXERCÍCIO", uma com lucro terminaria em "(=) LUCRO LÍQUIDO DO EXERCÍCIO" — um componente linha_dre comum (que casa por texto exato) quebraria assim que o resultado de uma empresa virasse de sinal entre uma apuração e outra. Resolvido em _contabil_resolve_componente_personalizado() (views.py) com um if a mais; no frontend, pidDcRenderComponentePicker() mostra só um texto explicativo pra esse tipo (nada pra selecionar).

Novo campo IndicadorContabilDefinicao.grupo (CharField, default "Indicadores Personalizados") — livre, só pra agrupar visualmente (mesmo conceito que já existia fixo no relatório antes da migração: "Indicadores de Resultado"/"Indicadores de Liquidez e Endividamento"). Não exposto no formulário de criar/editar (IndicadorContabilDefinicaoInputSerializer não aceita grupo) — só a migração de dados setou os dois grupos originais pros 11 migrados; um indicador criado pela tela sempre nasce em "Indicadores Personalizados" e não tem como mudar de grupo pela UI (poderia virar um campo editável numa rodada futura, se pedido). Migração 0062_indicadorcontabildefinicao_grupo_and_more (schema) — os 11 registros em si foram inseridos por um script Python ad-hoc rodado uma única vez direto no banco de produção (não uma management command, não fica no repositório), não pelo endpoint da API.

Verificação antes de ir pra produção (rigor extra por lidar com valor financeiro já exibido a cliente): antes de gravar qualquer coisa, um dry-run (mesmo script, dentro de uma transaction.atomic() com rollback forçado no final) criou os 11 registros, calculou os valores via _contabil_calcula_indicadores_personalizados() (motor novo) e comparou contra dashboard_contabil.indicadores.calcula_indicadores() (cálculo Python antigo) pra mesma apuração real (id=4, a única em produção nesta rodada) — os 11 bateram exatos até a vigésima casa decimal (inclusive o None do EBITDA, que não tem apuração anterior desta empresa). Só depois dessa conferência o script rodou de verdade (sem rollback). Testado de ponta a ponta em seguida contra a API/relatório reais (GET /indicadores/, GET /dashboard/, PATCH de edição incluindo um componente resultado_liquido) — tudo consistente.

dashboard_contabil/indicadores.py foi esvaziado, mas não apagado: CHAVES_CARDS/CHAVE_CARDS_RESULTADO/CHAVE_CARDS_LIQUIDEZ/MetaIndicador/METADADOS_CARDS foram removidos (zero consumidor depois da migração — mantê-los seria texto duplicado e defasável em relação à descricao/formula reais do banco). calcula_indicadores()/IndicadoresFinanceiros/_saldo()/_divide()/_busca_linha_por_trecho()/CODIGO_* foram deliberadamente mantidos, mesmo sem nenhum chamador em produção (ContabilApuracaoViewSet.dashboard()/.indicadores() não chamam mais _contabil_calcula_indicadores() — a função wrapper em views.py continua existindo, só não é mais invocada) — é a única exceção neste projeto à convenção de apagar código sem uso, justificada pelo risco real de indicador financeiro: se um valor um dia parecer suspeito, dá pra recalcular pelo caminho antigo e comparar contra o banco sem precisar reconstruir a fórmula de cabeça. Revisar se ainda vale a pena manter numa rodada futura, depois que a migração provar estabilidade em produção por um tempo.

Relatório "Gerar Dashboard" perdeu os 11 .dcr-card hardcoded — dashboard-contabil-relatorio.html agora tem um único {% for grupo in indicadores_grupos %} genérico (mesmo conceito do {% for indicador in indicadores_personalizados %} que já existia pra indicador personalizado antes desta rodada, agora é o único caminho de renderização, sem duplicação). _contabil_monta_cards_indicadores()/_contabil_agrupa_indicadores_cards() (views.py) montam a lista já com tudo pronto: valor formatado, valor cru (data-count), formato pro contador animado (data-format), regra de cor e destaque dourado só pros 11 migrados (_CONTABIL_INDICADOR_COR_REGRA/_CONTABIL_INDICADOR_DOURADO/_CONTABIL_INDICADOR_NOTA, dicts chave-fixa em views.py — réplica visual exata do que esses 11 já tinham hardcoded no template antes; indicador personalizado criado depois não entra em nenhum desses dicts, nasce sem cor/destaque/nota, mesmo como já era). Perda real, aceita conscientemente: os 11 cards tinham um ícone SVG próprio cada (gráfico de barras pro ROA, gota pra Liquidez Seca, etc.) — o loop genérico usa um ícone único pra todo indicador agora, não haveria como manter 11 ícones distintos sem mais uma tabela chave→SVG; mencionado ao usuário, não pedido de volta ainda. Revertido numa rodada posterior — ver "Ícone selecionável + flip card no relatório" mais abaixo.

Dois ajustes de acabamento no construtor de fórmula (mesma rodada da migração)

Scroll aninhado no construtor de componentes (pedido explícito do usuário, com captura de tela mostrando a área minúscula): .dc-ind-componentes-lista tinha seu próprio max-height:320px; overflow-y:auto (ver "Indicador padrão vs. não padrão" acima) por cima do .checklist-box de cada componente (max-height:160px, já rolável) — dois scrolls aninhados deixavam a área útil tão pequena que nem um componente inteiro cabia sem rolar duas vezes. Removido o scroll de .dc-ind-componentes-lista (volta a crescer no fluxo normal do modal, que já rola inteiro via .modal-card de components.css) e aumentado .dc-ind-comp-picker .checklist-box de 160px pra 260px — sobra só um scroll aninhado (o checklist em si, genuinamente necessário pelas ~100-150 contas/linhas de uma apuração real), não mais dois.

"Calcular com esta apuração" (pedido explícito do usuário: conferir os valores buscados, não só ler a fórmula em texto) — novo botão no modal "Novo/Editar Indicador", entre "Fórmula" e as ações do rodapé, que chama POST /api/contabil-apuracoes/{id}/pre-visualizar-indicador/ (ContabilApuracaoViewSet.pre_visualizar_indicador()) com o formulário ainda não salvo (nome/formato/fórmula/componentes tal como estão na tela) e mostra o valor de cada componente + o resultado final, calculados contra a apuração que está aberta na revisão — sem persistir nada, funciona tanto criando quanto editando. Reaproveita IndicadorContabilDefinicaoInputSerializer inteiro pra validar o corpo (inclusive a checagem de fórmula/componentes que criar/editar já fazem — nome é exigido pelo serializer mas não usado pra nada aqui, o frontend manda o que já estiver no campo, ou "Pré-visualização" se estiver vazio) e _contabil_resolve_componente_personalizado()/avalia_formula() (as mesmas funções do cálculo de verdade) sobre instâncias de IndicadorContabilComponente nunca salvas (só construídas em memória). Cada valor de componente é formatado com o filtro indice (número BR simples, sem R$/%, já que um componente pode ser qualquer grandeza — moeda, ratio de outro indicador, etc., não dá pra adivinhar o formato certo por componente); o resultado final usa o formato escolhido no formulário. Frontend: pidDcColetaComponentesForm() (extraída do handler de "Salvar", reaproveitada pelos dois) monta o payload; resposta renderizada em #dc-indicador-preview (.dc-ind-preview, dashboard-contabil.css), escondida sempre que o modal abre de novo (não carrega sozinho, só depois de clicar "Calcular" — evita mostrar um resultado desatualizado de uma edição anterior).

Nova consulta de histórico (_contabil_monta_historico_completo, views.py)

Diferente de _contabil_monta_historico (capada em 2 apurações anteriores, usada só pelas regras de auditoria no create()), esta busca todo o histórico da empresa — usada só pelo gráfico de evolução e pelo cálculo de Depreciação/Amortização do relatório. Recebe codigo_empresa/competencia_atual diretamente (não um CabecalhoExtraido) porque roda contra uma ContabilApuracao já persistida, ao contrário de _contabil_monta_historico (que roda durante o create(), antes de qualquer coisa existir no banco).

Ícone selecionável + flip card no relatório (rodada seguinte)

Pedido explícito do usuário, escopado por AskUserQuestion só pro relatório "Gerar Dashboard" (dashboard-contabil-relatorio.html) — a aba "Dashboard" da tela de revisão (dashboard-contabil.js/.html) não ganhou flip nem ícone nesta rodada, decisão deliberada pra não competir com os botões de olho/lápis que já existem em cada card ali.

Ícone volta a ser configurável por indicador — reverte a "perda aceita conscientemente" da migração anterior (ver acima). Campo novo IndicadorContabilDefinicao.icone (CharField, choices, default "barras" — o mesmo desenho hardcoded que todo indicador usava antes desta rodada, então nenhum indicador já cadastrado muda de aparência sem uma edição manual). Migração 0063_indicadorcontabildefinicao_icone. 11 opções curadas de ícone tipo KPI (barras/tendência de alta/tendência de baixa/percentual/pizza/atividade/cifrão/alvo/camadas/cartão/selo).

O desenho (miolo de <svg>) de cada ícone existe em duas cópias, mantidas em sincronia manualmente, não geradas uma a partir da outra — _CONTABIL_ICONES_SVG (portal_api/views.py, usado por _contabil_monta_cards_indicadores() pra montar card["icone_svg"] via mark_safe(), já que são só literais Python fixos neste arquivo) e PID_DC_INDICADOR_ICONES (static/js/dashboard-contabil.js, desenha a grade de botões do seletor no modal "Novo/Editar Indicador"). Duplicação proposital: o relatório é HTML puro servido pelo Django (sem acesso ao JS do app) e o modal de cadastro é só JS/HTML estático (a página dashboard-contabil.html é uma TemplateView sem contexto de servidor) — não dá pra ter uma fonte única sem inventar mais uma ida ao backend só pra isso. Editar/adicionar um ícone exige mexer nos dois lugares.

Seletor de ícone (#dc-indicador-icones, .dc-ind-icone-grid/.dc-ind-icone-btn em dashboard-contabil.css): grade de botões, cada um já o próprio preview (mesmo SVG que vai aparecer no card), .is-selecionado marca o ativo. dcIndicadorIconeSelecionado (estado em memória) é inicializado por pidDcAbrirIndicadorModal() (definicao.icone ao editar, "barras" ao criar) e incluído no payload de salvar; IndicadorContabilDefinicaoInputSerializer.icone (ChoiceField, default "barras") valida no backend.

Flip card no relatório — hover revela a descricao/fórmula do indicador (cadastradas em "Gerenciar Indicadores"). .dcr-card virou só a "cena" 3D (perspective + min-height:172px, necessário porque as duas faces do card são position:absolute agora e não contribuem mais pra altura do elemento); .dcr-card-inner é quem gira (transform:rotateY(180deg) no hover do .dcr-card pai, transition 480ms); cada face (.dcr-card-face--front/--back, backface-visibility:hidden) carrega o fundo/borda/sombra/padding que antes viviam direto em .dcr-card. O verso mostra nome + descrição ("Sem descrição cadastrada." se descricao estiver em branco — campo é opcional no model) + a fórmula. @media print já zera toda animation/transition globalmente, então o PDF/impressão sempre mostra a frente do card (não existe "hover" ativo numa impressão).

Fórmula do verso não é mais a expressão técnica de cálculo (ajuste na mesma rodada, depois de o usuário ver resultado_liquido - despesas_financeiras — as chaves internas dos componentes — no verso do EBIT e pedir um jeito de controlar o texto exibido ao cliente separado do cálculo real). Campo novo IndicadorContabilDefinicao.formula_exibicao (CharField, blank=True, migração 0064) — texto livre, sem nenhuma validação de sintaxe (ao contrário de formula, nunca passa por avalia_formula()/ast), só pra exibição; editável no modal "Novo/Editar Indicador" logo abaixo do campo técnico, agora rotulado "Fórmula (cálculo interno)" pra deixar claro que só formula_exibicao é "Fórmula (como aparece ao cliente)". _contabil_monta_cards_indicadores() resolve o fallback no servidor: definicao.formula_exibicao.strip() or definicao.formula — indicador sem essa preferência preenchida (todo indicador criado antes desta rodada, os 11 migrados inclusive) continua mostrando a fórmula técnica até alguém preencher pela tela, nunca fica sem nenhuma fórmula visível. Nenhuma mudança em formula em si (continua a mesma expressão validada, usada só pro cálculo) nem em IndicadorContabilComponente.

Verificado rodando o relatório de verdade via Client.force_login() num shell (manage.py shell, apuração id=4) — sem servidor de desenvolvimento nenhum aberto: a estrutura nova (dcr-card-inner, ícone certo por icone_svg), o fallback de formula_exibicao pra formula quando em branco, e o texto customizado aparecendo no lugar quando preenchido — os dois últimos testados trocando o campo de um indicador real (EBIT) e revertendo logo em seguida, sem deixar resíduo em produção. Confirmado também que "Sem descrição cadastrada" no card de um indicador que já tem descricao salva não é bug: o relatório é uma foto estática do momento em que foi gerado — editar a definição depois não atualiza um relatório já aberto/baixado, precisa clicar "Gerar Dashboard" de novo.

Dois ajustes de acabamento, mesma rodada: (a) .dcr-card__value (valor da frente) de 1.55rem pra 1.3rem — valor negativo em moeda (R$ -143.648,54) quebrava linha com a fonte maior; (b) .dcr-card-face__descricao perdeu flex:1; overflow-y:auto (um scroll aninhado por cima do scroll que já existe em .dcr-card-face--back) — descrição e fórmula agora fluem juntas num único bloco, rolando como texto contínuo em vez de ficar a fórmula presa numa área separada cortada.

A tela de revisão (dashboard-contabil.js) ganhou o mesmo destaque inicial do relatório: renderRevisao() agora inicializa dcContasDestaque/dcDreDestaque como cópias de dcContasColapsadas/dcDreColapsadas (new Set(dcContasColapsadas)), em vez de new Set() vazio — mesmo raciocínio do ajuste do relatório (ver "Destaque replicado no relatório 'Gerar Dashboard'" acima): as linhas colapsadas por padrão são o último nível já visível de cada ramo, então já nascem destacadas, sem precisar de nenhum clique. Reduz a poluição visual de abrir a tela inteira na cor de grupo/total (mesmo motivo que levou ao ajuste no relatório).

Botão de observação virou sempre ícone + cor do destaque invertida (tema escuro)

Dois pedidos na sequência sobre a tela de revisão (Balancete/D.R.E.):

Botão de observação: mostrar o texto da observação já preenchida direto na tabela (comportamento desde sempre) também poluía a coluna, além do "+ Observação" (já resolvido numa rodada anterior, ver acima) — o usuário pediu pra nunca mostrar texto nenhum na tabela, só o ícone, mudando de cor conforme preenchida ou não. PID_DC_OBSERVACAO_ICONE (constante única, sem mais um branch vazio/preenchido) é sempre o conteúdo do botão agora; .dc-conta-observacao-btn--preenchida (classe condicional em dashboard-contabil.js, tanto em renderContas() quanto em renderDre()) é a única diferença — troca a cor do ícone de --text-muted pra --accent. O texto da observação continua acessível via title/tooltip ("Adicionar observação" ou o texto em si) e pelo clique, que abre o modal de edição normalmente — só sumiu da própria célula da tabela. .dc-conta-observacao-btn virou sempre um botão circular 26px (antes só a variante vazia era assim; a variante "preenchida" tinha texto truncado com max-width/text-overflow, removido).

Cor do destaque no tema escuro invertida: --dc-destaque-bg (linhas destacadas) e --dc-row-tint-bg (as demais) trocaram de papel — antes o destaque usava --card-bg-hover (mais claro) e o resto ficava transparente (mais escuro, revelando o fundo da tabela); o usuário pediu o oposto, destaque mais escuro e o resto mais claro. Agora --dc-destaque-bg: var(--bg-canvas) (o tom mais escuro do tema) e --dc-row-tint-bg: var(--bg-surface-raised) (mais claro que --bg-canvas/--bg-surface, mas ainda diferente de --card-bg-hover — usar o mesmo tom do hover deixaria o hover das linhas não-destacadas sem nenhum efeito visível, já que ficariam idênticas em repouso e ao passar o mouse). Só o tema escuro mudou — o tema claro já tinha sido invertido por pedido anterior do usuário (não-destaque colorido, destaque em branco) e continua como estava, sem relação com este pedido.

Bug real: folha genuína ficava de fora do destaque inicial

Usuário reportou, testando a rodada anterior na D.R.E., que várias linhas-folha genuínas ((-) DE VENDAS DE MERCADORIAS MERCADO INTERNO, (-) SIMPLES NACIONAL, DESCONTOS OBTIDOS, várias linhas de resultado financeiro) não estavam com o destaque que deveriam ter, mesmo sendo visualmente "de baixo" quanto um grupo colapsado por padrão no mesmo nível. Causa: o destaque inicial usava só dcContasColapsadas/dcDreColapsadas (que só marca linha que tem filho escondido) — uma folha de verdade (sem filho nenhum, ex. (-) SIMPLES NACIONAL, código-fonte confirmado via manage.py shell: ContabilLinhaDre.objects.get(id=503).nivel == 2, sem nenhuma linha de nivel=3 logo depois) nunca entra nesse conjunto, então nunca ganhava destaque, mesmo estando no mesmo nível visual que um grupo colapsado vizinho.

Critério corrigido (dcUltimaLevaVisivel() em dashboard-contabil.js, calculaDestaqueInicial() na cópia irmã em dashboard-contabil-relatorio.html, mesmo algoritmo nas duas): reconstrói a lista de linhas realmente visíveis dado o colapso padrão (mesma pilha de níveis usada pra ocultar descendente de linha colapsada) e marca como destaque toda linha cuja próxima linha visível não seja mais profunda que ela — cobre os dois casos com uma regra só: nada foi revelado logo abaixo dela agora, seja porque está colapsada (filhos escondidos) ou porque é uma folha sem filho nenhum. Validado manualmente contra os níveis reais da apuração id=4 (ContabilLinhaDre ids 490-504) antes de aplicar — o algoritmo original (= colapsadas) incluía só 492/496 (grupos colapsados); o corrigido inclui também 501/503 (as duas folhas citadas pelo usuário), sem incluir 500/502 (grupos abertos com algo visível abaixo, corretamente fora do destaque).

Flip card: texto centralizado, fonte menor, fórmula em fonte monoespaçada mais elegante

Três ajustes finos de acabamento no verso do flip card do relatório, pedido explícito do usuário com uma captura de tela do flip card equivalente que ele já usa no Power BI como referência: (a) text-align:center em .dcr-card-face--back — herdado por título/descrição/rótulo/fórmula, nenhum precisou de regra própria; (b) fontes um pouco menores (.dcr-card-face__titulo 0.8rem→0.74rem, __descricao 0.78rem→0.7rem, __formula-label 0.66rem→0.62rem, __formula 0.74rem→0.7rem); (c) fórmula ganhou "JetBrains Mono" (peso 500, adicionada ao mesmo <link> do Google Fonts já usado por Inter/Manrope nesta página), com "Courier New", monospace como fallback.

Modal "Novo/Editar Indicador" ganhou confirmação ao fechar sem salvar

Pedido explícito do usuário: fechar o modal clicando fora (overlay) saía direto sem perguntar nada, arriscando perder o que já estava preenchido. pidDcFecharIndicadorModalComConfirmacao() (nova, async) chama pidConfirm("Sair sem salvar as alterações?", { perigoso: true }) antes de fechar de verdade — ligada ao clique no overlay e ao botão "Cancelar", os dois únicos caminhos de fechar iniciados pelo usuário (o modal não tem um "X" próprio). pidDcFecharIndicadorModal() em si continua sem confirmação nenhuma, chamada direto pelos fluxos de sucesso (salvar/excluir) — não haveria o que descartar depois de uma ação já concluída. Mesmo padrão já usado no modal de "Mais informações" (ajuda-aplicacao.js) e no antigo "Fechar sem salvar" do Cadastro de Regras de Plano de Saúde (ver "Modal de confirmação genérico" no CLAUDE.md da raiz).

Resumo do Fechamento — texto rico do contador antes dos indicadores

Pedido explícito do usuário, com um exemplo real de texto que a De Paula já manda ao cliente hoje por fora do Portal (carta com considerações/saldos/variações do fechamento) como referência do que deveria caber aqui. A aba "Indicadores" do relatório "Gerar Dashboard" (só cards de indicador até aqui) virou "Resumo" — nome da aba (data-dcr-tab="indicadores", atributo mantido igual, só o rótulo visível mudou) e do card dc-dash-section__titulo na aba "Dashboard" da revisão — porque agora carrega duas coisas: o texto livre do contador ("Resumo do Fechamento", sempre no topo) e, embaixo, os grupos de cards de indicador de sempre (inalterados).

Model: ContabilApuracao.resumo_fechamento (TextField, blank=True, migração 0065) — texto rico (HTML sanitizado), mesma allowlist nh3 (RICHTEXT_ALLOWED_TAGS/_ATTRS/_SCHEMES) de AcessoGeral.observacoes/AjudaAplicacao.texto; CONTABIL_RESUMO_FECHAMENTO_MAX_CHARS/validar_tamanho_resumo_fechamento_contabil seguem o mesmo padrão "constante+validator por campo" já usado pelos outros dois (o projeto não tem um validator de tamanho de texto rico genérico, é copiado por campo de propósito).

Backend: ContabilApuracaoViewSet não tem PATCH genérico (http_method_names exclui "patch" de propósito, ver docstring da classe) — mesmo padrão de indicadores_ocultos()/indicadores_selecionados(), uma @action dedicada nova (POST /api/contabil-apuracoes/{id}/resumo-fechamento/, resumo_fechamento()) substitui o campo inteiro de uma vez, validado por ContabilResumoFechamentoSerializer (validate_resumo_fechamento chama nh3.clean(), mesmo validate_texto/validate_observacoes de AjudaAplicacaoSerializer/AcessoGeralSerializer), guardada por _contabil_garante_em_revisao() (não editável depois de "Concluída", igual ao resto da apuração). Campo exposto em leitura via ContabilApuracaoDetailSerializer (não no ListSerializer — só a tela de revisão precisa dele).

Frontend — aba "Dashboard" (templates/dashboard-contabil.html/dashboard-contabil.js): editor contenteditable com colar/arrastar imagem embutida — mesmo padrão de .ag-richtext (acessos-gerais.js)/.ajuda-modal__editor (ajuda-aplicacao.js), duplicado aqui de propósito (nenhum desses três é um componente compartilhado no projeto; PID_DC_RESUMO_FECHAMENTO_IMAGE_MAX_BYTES = 2MB, mesmo limite dos outros dois). Populado só em renderRevisao() (uma vez por apuração aberta) — nunca em renderDashboardTab() (que roda toda vez que o contador troca pra essa aba): resetar o innerHTML a cada troca de aba descartaria texto ainda não salvo, diferente do padrão de "recarrega sempre" que os cards de indicador/observações usam ali (esses não são editados inline na mesma tela). Sem botão "Cancelar"/toggle editar-vs-visualizar (diferente de AjudaAplicacaoSerializer) — é sempre editável enquanto a apuração está "Em revisão" (contenteditable alternado via JS conforme status), porque só o próprio contador vê esta tela, ao contrário do texto de "Mais informações" (lido por qualquer usuário, editado só pelo perfil "Inovação").

Frontend — relatório (dashboard-contabil-relatorio.html): {{ apuracao.resumo_fechamento|safe }} dentro de .dcr-resumo-fechamento__texto, condicionado a {% if apuracao.resumo_fechamento %} (nada renderiza se a apuração não tem resumo). Primeiro |safe de template Django do projeto — até agora todo texto rico só existia em tela SPA, injetado via .innerHTML = no JS, nunca por um template renderizado no servidor; seguro aqui pelo mesmo motivo de sempre (já vem sanitizado por nh3 antes de salvar, nunca cru do request). Seção com fundo/borda/sombra próprios (.dcr-resumo-fechamento, visual de card, diferente das outras .dcr-secao que são só agrupamento sem fundo) — se destaca do restante da aba antes dos grupos de indicador.

Testado de ponta a ponta via Client.force_login() num shell: POST .../resumo-fechamento/ com um <script>alert(1)</script> embutido junto de HTML válido — confirmado que o nh3 removeu o <script> e manteve <p>/<strong>/<ul>/<li> intactos; conferido que o texto aparece no relatório antes de "Indicadores de Resultado" e que a seção some quando resumo_fechamento está vazio; revertido pro valor original (vazio) na apuração real (id=4) ao final do teste.

Análise Vertical — nova aba na revisão e no relatório (rodada 123)

Pedido explícito do usuário: a Demonstração Mensal (Análise Vertical), até então ignorada pelo parser (ver "Extração do PDF" acima), passou a ser exibida tanto na tela de revisão quanto no relatório "Gerar Dashboard" — escopo confirmado por AskUserQuestion antes de implementar: aba própria (não embutida na aba D.R.E.) e mesmos recursos por linha que Balancete/D.R.E. (observação inline, tri-state "validado", ocultar do relatório).

Backend: ContabilLinhaAnaliseVerticalViewSet (GET/PATCH em /api/contabil-linhas-analise-vertical/{id}/) é uma réplica exata de ContabilLinhaDreViewSet — mesmo _contabil_garante_em_revisao(), mesmo serializer com valores read-only. ContabilApuracaoViewSet.create() faz um terceiro bulk_create (depois de ContabilConta/ContabilLinhaDre) com as linhas de resultado.extracao.linhas_analise_vertical, e grava analise_vertical_meses já na criação da ContabilApuracao — vem pronto do parser, não precisa de nenhum cálculo adicional na view. dashboard() ganhou linhas_analise_vertical/analise_vertical_meses/observacoes_analise_vertical no contexto, reaproveitando _contabil_arvore_contexto() (mesma função da DRE, só que passando a lista/nivel_fn certos) — nenhuma função de árvore nova precisou ser escrita.

Frontend — tela de revisão (dashboard-contabil.html/.js): nova aba data-dc-tab="analise-vertical" (botão #dc-av-tab-btn, hidden quando analise_vertical_meses está vazio — relatório antigo sem essa seção não ganha uma aba com tabela vazia). renderAnaliseVertical() é uma réplica de renderDre() (mesmo algoritmo de pilha de colapso, mesmo tri-state de validado via dcValidadoInfo()/dcClicarValidadoAv() — as funções genéricas já existentes, dcColapsoPadrao/dcUltimaLevaVisivel/dcFilhosDiretos/dcDescendentes/dcEstadoValidacaoGrupo/dcValidadoInfo, já recebiam itens/nivelFn como parâmetro e foram 100% reaproveitadas sem alteração), só que cada <td> de valor vira N pares de colunas (Valor/Variação, uma dupla por mês) montadas a partir de linha.valores — o cabeçalho da tabela (renderAnaliseVerticalHead()) também é montado em JS porque o número de meses varia (normalmente 3, mas não é fixo). pidDcFormatPercentualAnaliseVertical() é um formatador novo, deliberadamente diferente de pidDcFormatIndicadorPercentual() — o percentual da Análise Vertical já vem "pronto" do PDF (ex. "100.00" quer dizer 100,00%), enquanto o dos cards de indicador é uma fração que precisa ser multiplicada por 100; reusar o errado exibiria 10000,00%. Editor de observação inline + resumo de observações no fim da aba seguem exatamente o padrão de Balancete/D.R.E. (dc-av-obs-*).

Se a apuração aberta não tem Análise Vertical mas a aba estava ativa (ex.: usuário estava vendo essa aba de uma apuração anterior e abre uma sem essa seção) — renderRevisao() força a volta pra aba "Observações" nesse caso específico, pra não deixar um botão de aba escondido com o painel dele ainda visível.

Frontend — relatório (dashboard-contabil-relatorio.html): 4ª aba data-dcr-tab="analise-vertical", entre "D.R.E." e "Resumo" — todo o bloco (botão + painel) fica dentro de {% if analise_vertical_meses %}, então some inteiro em relatórios de apurações antigas sem essa seção; pidDcrArvore("dcr-av-body")/pidDcrObs("dcr-av-body") (chamadas incondicionais no fim do script) já toleram um tbody inexistente (if (!tbody) return;, mesma guarda que essas duas funções já tinham). Reaproveita a mesma árvore recolhível/observação inline da DRE, sem nenhuma função nova. "Observações da Análise Vertical" tem sua própria seção no fim da aba, e a lista consolidada "Todas as Observações da Análise" (aba "Resumo") ganhou um 3º grupo (prefixo "Análise Vertical — ...", ícone de gráfico de barras) ao lado de Balancete/D.R.E./Auditoria — a mesma extensão simples de sempre, um {% for %} a mais.

Dois filtros de template novos em contabil_extras.py — moeda_av/percentual_av — porque ContabilLinhaAnaliseVertical.valores grava valor/percentual como texto (não Decimal, ver acima), e os filtros moeda/percentual existentes não servem: moeda faria f"{valor:,.2f}" falhar contra uma string, e percentual multiplicaria por 100 (pensado pra fração, não pra um percentual "já pronto" como o desta seção).

Validado ponta a ponta via Client.force_login() num shell, contra o PDF de referência real (792 - balancete 072026.pdf, empresa 0792/WEITNAUER, competência 07/2026): POST /api/contabil-apuracoes/ extraiu 154 linhas de Análise Vertical (3 meses: mai/jun/jul de 2026) além das 177 contas/163 linhas de DRE de sempre; GET .../dashboard/ gerou o relatório com a aba "Análise Vertical" presente; PATCH numa linha (observação + oculta_no_relatorio=False + validado=True) persistiu corretamente e a observação apareceu no relatório gerado em seguida. Toda apuração/arquivo criados durante o teste foram apagados ao final, sem deixar resíduo em produção.