# 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`), ``/`<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 — decisão revisitada numa rodada seguinte, ver `regra_variacao_atipica_dre` abaixo). É 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; **cada valor já vem isolado por mês** (não acumulado desde janeiro como a DRE principal — confirmado comparando os dois: `linhas_dre` de julho é o YTD jan-jul, `linhas_analise_vertical` de julho é só o mês de julho). `percentual` é a análise vertical de verdade (percentual da linha sobre a Receita Operacional Bruta *daquele mês*, não uma variação percentual mês a mês). `_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. - `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` (só as últimas 2 apurações já persistidas da mesma empresa, resolvidas por `_contabil_monta_historico()` em `views.py`) continua na assinatura de toda regra por uniformidade (`gera_achados()` chama todas do mesmo jeito) — mas **nenhuma das 9 regras atuais usa esse parâmetro** desde que `variacao_atipica_dre` migrou pra Análise Vertical (rodada seguinte, ver abaixo); as duas regras que dependiam de `historico` (`variacao_atipica_saldo`/`percentual_custo_receita_atipico`) foram removidas na mesma rodada. `_contabil_monta_historico`/o parâmetro `busca_historico` de `pipeline.processa_apuracao()` continuam existindo de propósito (ponto de extensão já desenhado — ver "`ContabilApuracaoViewSet.create()`" abaixo), não foram arrancados só porque nada os usa hoje. 1. **`balanceamento_ativo_passivo`** (alta) — soma do grupo Ativo (`codigo="1"`) deve fechar **exatamente** com a do Passivo (`codigo="2"`, já vem negativo no relatório) — diferença precisa ser zero, sem tolerância de centavos (removida numa rodada seguinte, pedido explícito do usuário). 2. **`debito_credito_divergente`** (alta) — soma de Débito das contas-raiz (`codigo` sem ponto, ou seja só "1" e "2") deve bater **exatamente** com a soma de Crédito (mesma remoção de tolerância). **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. **`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) **e exceto toda conta descendente de uma conta redutora** (`_indices_descendentes_de_conta_redutora()`, rodada 144 — bug real, ver abaixo): se a conta "mãe" (sintética, em qualquer nível acima, não só o pai direto) começa com `"(-)"`, o sinal "invertido" dos analíticos dentro dela é o comportamento esperado da própria natureza daquela conta, não uma inconsistência. 5. **`lucro_balancete_diverge_dre`** (alta, rodada seguinte) — o resultado do exercício (lucro **ou** prejuízo) precisa ser o mesmo valor no Balancete e na DRE, sem tolerância. Lê `CODIGO_LUCRO_PREJUIZO_EXERCICIO` (`"2.04.13.002"`, código de classificação fixo pra linha sintética "LUCROS/PREJUÍZOS DO EXERCÍCIO" dentro do Patrimônio Líquido — calibrado contra os 2 balancetes reais já em produção, mesmo padrão de risco de `CODIGO_CAIXA`/`indicadores.CODIGO_*`; agrega "LUCROS DO EXERCÍCIO" ou "(-) PREJUÍZOS DO EXERCÍCIO" conforme o resultado do mês), negado (mesma convenção Passivo/PL com sinal invertido de `balanceamento_ativo_passivo`) contra `linhas_dre[-1].valor` (última linha da DRE — mesma fonte que `_ContabilDadosIndicadores.resultado_liquido` em `views.py` já usa pros indicadores). Validado batendo exato contra os 2 balancetes reais antes de entrar em produção. 6. **`conta_transitoria_com_saldo`** (média) — descrição contém "TRANSIT" com `saldo_atual != 0`, **exceto** a palavra isolada "TRANSITO" (`\bTRANSITO\b`, "dinheiro em trânsito" — conceito diferente de conta transitória/de compensação, excluído numa rodada seguinte). A exclusão é por palavra isolada, não um trecho maior como "TRANSITOR": testando contra `1751 - Balancete 07.2026.pdf` antes de decidir, a fonte embutida corrompe o acento de "TRANSITÓRIA" num caractere ilegível (não recuperável) na extração — "TRANSITOR" nunca bateria com essa conta (que tem saldo real), então a mudança pra um trecho positivo mais longo foi descartada a favor de manter "TRANSIT" e só excluir o falso positivo conhecido. 7. **`conta_deveria_zerar`** (média) — `TRECHOS_CONTA_DEVERIA_ZERAR` (lista curta e deliberadamente restrita — só "ADIANTAMENTOS DE SALÁRIOS", que o ITD confirma dever ficar zerada todo mês; **não** inclui "Adiantamento de Férias"/"13º Salário", que legitimamente carregam saldo entre meses). 8. **`descricao_generica`** (baixa) — descrição exatamente `"DIVERSOS"` com saldo relevante (o ITD cita esse caso especificamente: "o contador deverá realocar estes lançamentos a conta pertinente"). 9. **`variacao_atipica_dre`** (baixa — era média até uma rodada seguinte, ver abaixo; **reescrita numa rodada anterior** — pedido explícito do usuário, "utilizar a análise vertical") — não depende mais de `historico`/apurações anteriores do Portal. Usa a própria seção "Demonstração Mensal (Análise Vertical)" do PDF (`atual.linhas_analise_vertical`, quando presente): compara só os **2 meses mais recentes** dessa tabela (ex. jun → jul), pelo `percentual` (já isolado por mês, não acumulado) de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos `VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO` (1 ponto percentual — piso pra não disparar em saltos %-mente grandes só porque a base de comparação já era perto de zero) **e**, quando o percentual anterior não é zero, `VARIACAO_LIMIAR_PERCENTUAL` (**65%** de variação relativa sobre ele — era 50%, ajustado a pedido explícito do usuário numa rodada seguinte, junto da troca de severidade pra baixa: "variações acima de 65%... como prioridade baixa"). Roda mesmo na 1ª apuração de uma empresa nova, desde que o PDF traga essa seção — validado contra os 2 balancetes reais (16 e 15 achados respectivamente; ambos com bastante movimento real entre os 2 meses do próprio PDF) no limiar original de 50%, antes do ajuste pra 65%. **Duas regras removidas na mesma rodada** (pedido explícito do usuário, "utilizar a análise vertical" no lugar delas): `variacao_atipica_saldo` (variação de saldo de conta do Balancete contra a apuração anterior) — a Análise Vertical do PDF só cobre linhas da DRE, não contas do Balancete, então não tinha como reaproveitar a mesma fonte pra ela, e a alternativa de mantê-la como estava (usando `historico`) foi descartada a favor de simplificar o motor; `percentual_custo_receita_atipico` (razão Custos/Receita Líquida do mês contra o mês anterior, via `historico`) — a granularidade maior de `variacao_atipica_dre` sobre a Análise Vertical já cobre esse caso (e qualquer outra linha da DRE) sem precisar de uma regra dedicada. **Bug real, rodada 144 — `saldo_sinal_invertido` disparava pra conta descendente de uma redutora, não só a filha direta**: usuário reportou, com print de achados reais ("Conta do Passivo 'DANITHI LTDA'"/"'MPASARABIAHOLDINGPARTICIPAÇÕES E'" com saldo devedor, ambas `2.04.01.003.001`), que o filtro de conta redutora (`_eh_conta_redutora()`, checa se a própria descrição começa com `"(-)"`) só excluía a conta que **em si** tem o prefixo — não bastava a "mãe" (sintética, em qualquer nível acima, não só o pai direto) ter o sinal `"(-)"`: as duas contas do print são analíticas dentro de `2.04.01.003` `"(-) CAPITALA INTEGRALIZAR"` (sic — espaço grudado, mesma classe de artefato de extração já documentada em "PDF de fonte atípica" acima; confirmado contra a apuração real `1751`/TAROBA em produção), então herdam o sinal devedor esperado da conta-mãe, mas não tinham `"(-)"` na própria descrição — geravam achado indevido. Corrigido com `_indices_descendentes_de_conta_redutora(contas)` (novo, `regras.py`) — calcula, pra toda a árvore de uma vez, quais índices têm **algum** ancestral redutora, usando o mesmo algoritmo de pilha de níveis já usado em todo o resto da aplicação pra árvore de contas (`codigo.count(".")` + ordem de leitura do PDF, mesmo espírito de `dcUltimaLevaVisivel()`/`_contabil_arvore_contexto()`) — **não** comparação de prefixo de código: o Questor reaproveita o mesmo código de classificação entre contas analíticas irmãs (mesmo problema já documentado em `ContabilObservacao.chave_conta()`), então string matching por código não seria confiável pra achar o pai; a pilha de níveis segue a ordem/profundidade real da árvore impressa no PDF, funciona mesmo com códigos repetidos entre irmãos. `regra_saldo_sinal_invertido()` passou a pular toda conta cujo índice está nesse conjunto, além da checagem já existente na própria conta. Validado de duas formas: (1) árvore sintética reproduzindo exatamente a estrutura de um exemplo do usuário (conta "(-) LUCROS DISTRIBUÍDOS" com 2 sócios dentro) — confirmado que o achado deixa de ser gerado com a correção, e que **seria** gerado sem ela (não um teste vazio por acidente); (2) rodado contra as **3 apurações reais já em produção** — a apuração `1751`/TAROBA (a mesma do print do usuário) tem exatamente os 2 achados dos sócios suprimidos, e as 3 apurações somadas têm 32 contas identificadas como "descendente de redutora" (a maioria contas de depreciação acumulada, `1.02.05.007.*`, mesmo padrão "(-) DEPREC. ..."), sem nenhum falso positivo óbvio nos nomes. **Não afeta achados já persistidos** — os 2 achados reais da apuração `1751` (`id=20`/`21`, ainda `pendente` no banco) só somem da tela depois que essa apuração for reprocessada (`_contabil_recria_achados()`, rodada 143, roda o motor de regras de novo do zero); não foi feita nenhuma edição direta no banco pra removê-los manualmente. ## 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()`, só muda de `status` (`pendente`/`tratado`/`ignorado`) via `ContabilAchadoViewSet`, sempre com `observacao_contador` obrigatória ao mudar de pendente. **Exceção**: um reprocessamento apaga e recria **todos** os achados da apuração do zero (`_contabil_recria_achados()`, rodada 143 — decisão revisada, ver "Reprocessar" abaixo), então "nunca é apagado" só vale fora desse fluxo. `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 todo grupo já expandido** (pedido explícito do usuário, mesma rodada do ajuste de limiar acima, com o mecanismo revisado na rodada 140 abaixo): 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`), e **mais nada** fica destacado enquanto houver grupo aberto. Dois `Set()` por árvore (os 6 resetados em `renderRevisao()` e no botão "Restaurar formatação"): `dcContasExpandidos`/`dcDreExpandidos`/`dcAvExpandidos` guardam os ids dos grupos que o contador expandiu manualmente e que continuam expandidos, e `dcContasDestaque`/`dcDreDestaque`/`dcAvDestaque` guardam o resultado **derivado** (as linhas de fato destacadas), sempre recalculado por `dcDestaqueGrupos(itens, nivelFn, colapsadas, expandidos)`: - `expandidos` vazio (nada aberto ainda) → vale a leva padrão, `dcUltimaLevaVisivel(itens, nivelFn, colapsadas)`, mesma coisa do estado inicial de uma apuração recém-aberta. - `expandidos` com algo → destaque é **só** a união de `dcFilhosDiretos(itens, nivelFn, id)` de cada grupo aberto (função genérica reaproveitada pelas 3 árvores: varre a lista plana a partir do índice do item e para no primeiro item de nível igual ou menor, coletando só os de nível exatamente `pai+1`), descartando a leva padrão por completo. O handler de clique do toggle guarda `estavaColapsada = colapsadas.has(id)` **antes** de mutar o `Set` de colapso, e então: expandir adiciona o `id` a `expandidos`; recolher remove o `id` **e todo descendente dele** (`dcDescendentes()`) de `expandidos` — um grupo aninhado que estava aberto dentro do que acabou de fechar deixa de contar pro destaque, senão o destaque apontaria pra linhas agora invisíveis e a árvore nunca voltaria ao estado padrão. Puramente visual, sem persistência — reseta a cada apuração aberta, sem afetar nenhum dado gravado. **Bug real, rodada 140 — destaque de um grupo sumia ao expandir outro, e as duas primeiras correções não resolveram**: na versão original, expandir SUBSTITUÍA o `Set` de destaque pelos filhos diretos do grupo recém-aberto (empilhando o anterior numa pilha, restaurada ao recolher) — usuário reportou, com print de uma árvore de 3 níveis, que expandir uma segunda conta "apagava" o destaque da primeira, quando o esperado era as duas conviverem pra facilitar a validação em sequência. Duas tentativas falhas antes da correção final, ambas reportadas pelo usuário testando em produção (sempre com hard refresh + restart do servidor, então nunca foi cache): 1. **Somar os filhos revelados ao `Set` já existente** (mantendo o que estava): não mudou nada visualmente. Causa, confirmada simulando o algoritmo em Python contra as contas reais da apuração do usuário: o destaque inicial (`dcUltimaLevaVisivel`) já marca **praticamente toda conta de nível ≥ 2** — todas nascem recolhidas pelo limiar padrão, então cada uma é "fim de ramo visível" por si só. Somar os filhos revelados a essa base deixava a tabela inteira destacada, e com tudo destacado nada se destaca. O comportamento original só parecia funcionar porque o "substituir" limpava todo o resto da tabela. 2. **Destacar a própria conta expandida** em vez dos filhos: o usuário esclareceu que destacar os filhos estava certo desde o começo — a única mudança pedida era acumular mais de um grupo. Correção final é a descrita acima (`dcDestaqueGrupos()` com a base padrão **descartada** enquanto houver grupo aberto). Validada simulando o algoritmo em Python contra as contas reais da apuração `2021`/`08-2026`, reproduzindo o roteiro exato do usuário: expandir "CAIXA E EQUIVALENTES DE CAIXA" destaca só seus 2 filhos (resto da tabela limpo); expandir "CLIENTES" em seguida mantém os 2 primeiros e soma "DUPLICATAS A RECEBER"; fechar "CLIENTES" volta aos 2 primeiros; fechar "CAIXA" devolve exatamente o `Set` de destaque inicial (comparação de igualdade entre os dois conjuntos bateu). Mesmo ajuste replicado em `pidDcrArvore()` (`dashboard-contabil-relatorio.html`, réplica em JS puro desta mesma lógica no relatório "Gerar Dashboard", com `calculaDestaque()`/`expandidos`/`descendentes()` espelhando as funções do JS) — os dois lados são mantidos manualmente em sincronia, ver "Balancete e D.R.E. têm árvore recolhível igual à tela de revisão" abaixo. **Botão "+" (expandir tudo) no cabeçalho da primeira coluna** (pedido explícito do usuário, rodada seguinte — a ideia inicial incluía um "−" que recolhesse um nível por vez, descartada pelo próprio usuário na mesma conversa, "poderia apenas um botão"): `.dc-expandir-tudo-btn` (`data-dc-expandir-tudo="balancete|dre|analise-vertical"`), dentro de um `.dc-th-linha.dc-th-linha--inicio` (modificador novo, só troca o `justify-content` pra `flex-start` — o botão vem **antes** do rótulo, ao contrário do "Restaurar formatação", que fica colado na borda direita da última coluna). Abre a árvore inteira de uma vez (`dc*Colapsadas = new Set()`), até a conta analítica mais funda, nas 3 tabelas (cada uma com o seu botão, mesmo motivo do botão de restaurar: estados de colapso independentes). **Não é um toggle** — o caminho de volta é o botão "Restaurar formatação padrão" que já existe na coluna "Observação" da mesma tabela. Ponto não-óbvio: expandir tudo **zera** `dc*Expandidos` em vez de populá-lo com todos os grupos. Com o conjunto vazio, `dcDestaqueGrupos()` cai na leva padrão (`dcUltimaLevaVisivel`), que sem nada recolhido marca exatamente as **folhas** — validado contra a apuração real `2021`/`08-2026`: 133 contas visíveis, 74 destacadas, exatamente as 74 contas sem filhos (igualdade de conjuntos conferida). Popular `dc*Expandidos` com todos os grupos destacaria quase toda linha da tabela, que é o mesmo problema de "tudo destacado, nada se destaca" da tentativa falha nº 1 acima. O cabeçalho da Análise Vertical é montado em JS (`renderAnaliseVerticalHead()`, número de colunas varia com os meses), então lá o botão nasce no template string — nos outros dois é HTML fixo em `dashboard-contabil.html`; os listeners são delegados no `<thead>`, então sobrevivem ao `innerHTML` ser refeito a cada render. **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 **9 regras de `regras.py`** por chave (`achado.regra`, campo que já existia no model `ContabilAchado`) — eram 10 até uma rodada seguinte, quando `lucro_balancete_diverge_dre` entrou e `variacao_atipica_saldo`/`percentual_custo_receita_atipico` saíram (ver "Regras de auditoria v1" acima). **Cards agrupados por tema, não um card por regra** (rodada seguinte à criação do resumo, pedido explícito do usuário: a versão original — um card por regra, 10 ao todo na época, 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 9 regras atuais em 3 temas fixos: "Divergências de Saldo" (balanceamento Ativo x Passivo, débito x crédito, caixa negativo, sinal de saldo invertido, lucro do balancete x DRE), "Contas Atípicas" (contas transitórias, contas que deveriam zerar, descrição genérica) e "Variações e Indicadores" (variação atípica na DRE, única regra do grupo desde que as outras duas saíram) — agrupamento original alinhado com o usuário antes de implementar (via pergunta com preview), mantido ao remapear as regras novas/removidas pros mesmos 3 temas. 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 todas as 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, descendentesFolhas)`: - `"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 FOLHA está validado, checado primeiro. Usa só `descendentesFolhas` (`dcDescendentes(..., somenteFolhas=true)`), não `descendentes` completo — **bug real, rodada 139**: numa árvore de 3+ níveis (mãe → filha → netos), validar os netos direto sem clicar na própria "filha" intermediária nunca marca o campo `validado` dela no banco (só o estado visual dela é "completo", calculado por render); checar todo `descendentes` (incluindo a "filha") na "mãe" fazia o grupo nunca fechar, mesmo com todo neto validado. `descendentes` (todos, não só folha) continua valendo pro "parcial" — uma sintética marcada sozinha, sem cascatear, ainda deve sinalizar "em andamento" num ancestral. `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) 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 (mesma fonte que `regra_lucro_balancete_diverge_dre` usa em `regras.py`, ver "Regras de auditoria v1" acima). 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). 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**; 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 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: **Balancete** (aba inicial) → **D.R.E.** → **Análise Vertical** (só quando a apuração tem essa seção, ver rodada 123 abaixo) → **Resumo** (rótulo visível; `data-dcr-tab`/`data-dcr-panel` internos continuam `"indicadores"`, nome que nasceu antes de virar uma aba com mais coisa além dos cards — os dois grupos de indicador, o Resumo do Fechamento e as Observações consolidadas). Na impressão (`@media print`), a barra de abas some e todas 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 removido numa rodada seguinte** (pedido explícito do usuário: "temos a análise vertical para esta visualização" — a aba Análise Vertical já cobre esse tipo de comparação mês a mês, o gráfico ficava redundante). Existia via Chart.js (`<script src="https://cdn.jsdelivr.net/npm/chart.js@4">`) com a série injetada por `{{ evolucao|json_script:"dcr-evolucao-data" }}`; os três (o `<script>` do CDN, o `json_script`, e a função `inicializaGrafico()`/`<canvas id="dcr-grafico-evolucao">`) foram removidos do template, e `evolucao`/o loop que a montava a partir de `dados.historico_completo` saiu de `dashboard()` em `views.py` — `_contabil_monta_historico_completo()` continua existindo, só não alimenta mais esse gráfico (ver "Depreciação/Amortização" acima, seu outro consumidor). A variável de controle que só existia pra adiar a inicialização do gráfico (`graficoInicializado`) virou `cardsInicializados`, já que sobrou só a contagem animada dos cards de indicador pra adiar. `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. ### Exportação em PDF do Resumo (`resumo_pdf.py`, rodada seguinte) Pedido explícito do usuário: um "Exportar PDF" dentro da aba **Resumo** do relatório "Gerar Dashboard", pra baixar Resumo do Fechamento + Indicadores + Observações num documento avulso — sem o resto do relatório (Balancete/D.R.E./Análise Vertical, que já têm seu próprio caminho de exportação em XLSX). `ContabilApuracaoViewSet.resumo_pdf()` (`GET /api/contabil-apuracoes/{id}/resumo-pdf/`, mesma permissão de toggle único, mesmo `GET`-não-`POST` de `dashboard()` — abre via `window.open()`/link direto, não fetch+blob) chama `_contabil_dados_resumo(apuracao)` (a mesma função que `dashboard()` usa pra montar `indicadores_grupos`/observações da aba Resumo — extraída numa refatoração desta mesma rodada, ver função em `views.py`) e `dashboard_contabil.resumo_pdf.gera_pdf_resumo()`. **`_contabil_dados_resumo(apuracao)`** (`views.py`) — extraído de dentro de `dashboard()` pra ser reaproveitado pelos dois: calcula `indicadores_grupos` (mesmo caminho de sempre — `_contabil_coleta_dados_indicadores`/`_contabil_calcula_indicadores_personalizados`/`_contabil_monta_cards_indicadores`/`_contabil_agrupa_indicadores_cards`) e devolve `observacoes_visiveis` **sem** separar por `alvo_tipo` nem calcular `ancora` (isso é específico do relatório HTML, que separa em 3 listas e pendura `ancora` pra permitir o clique-até-a-conta — ver rodada anterior) — o PDF só lista todas juntas, na mesma ordem/agrupamento de "Todas as Observações da Análise". `dashboard()` chama essa função e continua fazendo, por cima, a separação por tipo + `_com_ancora()` que só ele precisa. **`resumo_pdf.py`** (pacote puro, sem ORM — recebe a `ContabilApuracao` já carregada e o dict de `_contabil_dados_resumo()`, mesmo espírito de `indicadores/recibo.py`/`custo_contratacao/pdf.py`): banner roxo/dourado com `logo-branco.png` (identidade do escritório, nunca "P.I.D." — ver "Logos" no CLAUDE.md raiz), replicando a paleta do próprio relatório HTML (`#3d2178`/`#281552`/`#b4872a`). Três seções, na mesma ordem da aba Resumo: - **Resumo do Fechamento**: o texto rico do contador (`apuracao.resumo_fechamento`, já sanitizado por `nh3` — allowlist fechado `RICHTEXT_ALLOWED_TAGS` em `serializers.py`: `p`/`br`/`div`/`b`/`strong`/`i`/`em`/`u`/`ul`/`ol`/`li`/`img`) convertido em flowables do reportlab por `_resumo_fechamento_flowables()` (BeautifulSoup, já em `requirements.txt` como dependência transitiva de outra ferramenta — não precisou adicionar nada novo). `_inline_markup()` reconstrói `b`/`i`/`u`/`br` aninhados na marcação nativa que o `Paragraph` do reportlab já entende (`<b>`/`<i>`/`<u>`/`<br/>`), `strong`→`b`/`em`→`i`; `ul`/`ol` viram `ListFlowable`; `<img src="data:image/...">` (a única forma de imagem que o editor produz, colar/arrastar arquivo) vira um `Image` decodificado de base64 direto em memória, redimensionado pra caber na largura útil da página. Seção inteira pulada se `resumo_fechamento` estiver vazio. - **Indicadores**: uma `Table` por grupo (`_tabela_indicadores()`, mesmo padrão de "seção = uma Table com barra de título" de `_tabela_secao()` em `custo_contratacao/pdf.py`) — nome/valor formatado de cada card, sem fórmula/descrição/ícone (esses só fazem sentido no verso do flip card da tela, não num PDF estático). - **Observações da Análise**: todo `ContabilObservacao` visível + `achados_com_observacao`, cada um com o mesmo prefixo de origem do relatório HTML ("Balancete — ...", "D.R.E. — ...", "Análise Vertical — ...", "Auditoria — ..."), texto e assinatura. "Nenhuma observação registrada nesta análise." quando vazio (mesmo texto do relatório HTML). Botão (`<a class="dcr-export-btn" href="/api/contabil-apuracoes/{{ apuracao.id }}/resumo-pdf/">Exportar PDF</a>`, `.dcr-resumo-toolbar` — uma barra dedicada no topo da aba Resumo, já que a aba tem várias seções sem um único `<h2>` fixo pra pendurar o botão, diferente de Balancete/D.R.E./Análise Vertical) — escondido em `@media print` (reaproveita a regra já existente de `.dcr-export-btn`, só precisou esconder o wrapper `.dcr-resumo-toolbar` também, pra não sobrar um espaço vazio). Validado com `Client.force_login()` contra uma apuração real já em produção (leitura, nada escrito) e com testes unitários isolados de `_resumo_fechamento_flowables()`/`gera_pdf_resumo()` (sem tocar no banco) cobrindo negrito/itálico/lista/imagem embutida, resumo vazio e zero observações — os três casos renderizam um PDF válido (`pdfplumber` confirmou o texto esperado em cada página). ### 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. A exportação XLSX de Balancete/DRE **não** é afetada — não é "card de indicador" nem "observação", ficou 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 cálculo de Depreciação/Amortização do relatório (EBITDA); alimentava também o gráfico de evolução do Resultado Líquido, removido numa rodada seguinte (ver "Relatório 'Gerar Dashboard'" acima). 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 na aba (posição revisitada numa rodada seguinte — ver "Seção 'Observações do X' fica ACIMA da tabela" mais abaixo), 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. ### Reprocessar — anexar um PDF novo pra mesma empresa/competência sem perder observações/achados (rodada 124) Pedido explícito do usuário: até aqui, corrigir uma apuração com o PDF errado/incompleto exigia excluir e recriar do zero (ver docstring antiga de `concluir()`), perdendo toda observação/validação/achado já registrado. Botão "Reprocessar" novo (ícone ao lado de "Abrir", na lista — só aparece em apuração "Em revisão") abre um modal só com o campo de arquivo; o PDF novo precisa ser da **mesma** `codigo_empresa`+competência (senão 400 — trocar de empresa é uma análise nova, não um reprocessamento). Escopo do que preserva/reseta confirmado com o usuário nesta rodada: contas/linhas sem mudança mantêm observação/validado como estavam; contas/linhas que mudaram voltam pra `validado=False` e ganham um alerta visual; achados **nunca são apagados nem têm status/justificativa sobrescritos**, mesmo os que não disparam mais com os dados novos (confirmado explicitamente via `AskUserQuestion` — é pra manter o histórico completo de tratativa). **Decisão sobre achados revertida na rodada 143** (ver "Achados são recriados do zero a cada reprocessamento" mais abaixo) — o comportamento atual é o oposto do descrito aqui: achado é apagado e recriado do zero a cada reprocessamento, só a parte de conta/linha (parágrafo acima) continua como decidido nesta rodada. **Modelos**: campo novo `alterada_reprocessamento` (`BooleanField`, default `False`) em `ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical` (migração `0068`) — marca que aquela conta/linha mudou no último reprocessamento; o frontend mostra um alerta ao lado do ícone de observação enquanto for `True`. Migração `0069` acrescentou o valor de antes da mudança, só pro tooltip do badge (pedido explícito do usuário — "mostre o valor que estava antes do reprocessamento" ao passar o mouse): `valor_anterior_reprocessamento` (`DecimalField`, `null=True`) em `ContabilConta` (cópia de `saldo_atual`) e `ContabilLinhaDre` (cópia de `valor`); `valores_anterior_reprocessamento` (`JSONField`, mesmo formato de `valores`) em `ContabilLinhaAnaliseVertical`, já que ali não existe um valor único (um por mês). Os três são `read_only` no serializer — só `_contabil_sincroniza_*` grava, nunca um PATCH de cliente. **`ContabilApuracaoViewSet.reprocessar()`** (`POST /api/contabil-apuracoes/{id}/reprocessar/`, multipart `arquivo`) — bloqueado por `_contabil_garante_em_revisao()` (mesmo gate de qualquer edição; não existe "reprocessar uma apuração Concluída"). Roda o mesmo `dashboard_contabil_pipeline.processa_apuracao()` de `create()`, confere `codigo_empresa`/competência batendo com a apuração existente, troca o `arquivo` (apaga o antigo só **depois** do commit da transação, mesmo cuidado de sempre com storage não-transacional) e delega a resincronização pra 4 funções puras — cada uma com uma estratégia diferente, conforme o que precisa (ou não) persistir através de um reprocessamento: - `_contabil_sincroniza_contas()`/`_contabil_sincroniza_linhas_dre()`/`_contabil_sincroniza_linhas_analise_vertical()` — **atualização no lugar (mesmo `id`), não delete+recria**: casam cada conta/linha extraída contra a existente (`codigo` pro Balancete; `(descricao, nivel)` pra DRE/Análise Vertical, mesma convenção de chave natural já usada pelo histórico de variação em `regras.py`/`_contabil_monta_historico()` — o par `(descricao, nivel)` desambigua a maioria das descrições repetidas em ramos diferentes da árvore, ex. "COMISSÕES SOBRE VENDAS" aparecendo em mais de um nível, um risco real confirmado contra o PDF de referência). Casada: atualiza os campos brutos **no mesmo registro** (`.save()`, nunca `bulk_create`/delete); se algum campo relevante mudou, força `validado=False` e `alterada_reprocessamento=True`, guardando o valor de antes em `valor_anterior_reprocessamento`/`valores_anterior_reprocessamento` (sempre lido **antes** de sobrescrever o campo com o valor novo, na mesma função), senão preserva tudo (inclusive limpando esse campo pra `None`/`[]`) como estava. `observacao`/`oculta_no_relatorio` nunca são tocados por essas funções. Sem match na nova extração: `ContabilConta.objects.create()`/equivalente, nasce com os defaults de sempre (`validado=False`, sem o alerta — não tem "antes" pra comparar). Sobra no mapa antigo sem match na nova extração: `.delete()`. Mantidas assim (não delete+recria) porque `codigo`/`(descricao, nivel)` **é** a identidade da conta/linha do ponto de vista do contador — o mesmo id continua referenciado por `ContabilObservacao` (chave natural, mas ainda assim o `id` da conta importa pra `_contabil_arvore_contexto()` no relatório) e é preciso preservar `validado`/observações de itens que não mudaram. - `_contabil_recria_achados()` (**rodada 143, revisão de uma decisão anterior** — ver abaixo) — **delete+recria total**, mesmo `bulk_create` de `create()`: apaga **todos** os achados da apuração e gera um conjunto novo a partir das regras rodadas sobre o PDF novo. Todo achado nasce `pendente`, mesmo que a mesma `(regra, conta)` já tivesse sido tratada antes do reprocessamento. **Por que os dois grupos usam estratégias opostas**: conta/linha é dado extraído do PDF que o contador **anota** (observação/validado) — o valor de hoje precisa ser atualizado, mas a anotação de ontem sobre a mesma conta continua valendo, então "atualiza no lugar" preserva tudo que não mudou. Achado é um **apontamento derivado**, recalculado inteiramente a cada rodada das regras — não existe "achado que não mudou", ele ou dispara com os dados de agora ou não dispara; manter um achado "tratado" que não dispara mais equivalia a mostrar uma inconsistência que já não existe. **Frontend** (`dashboard-contabil.html`/`.js`): botão de ícone (refresh) ao lado de "Abrir" na lista (`data-dc-reprocessar-abrir`, escondido quando `status === "concluida"`) abre `#dc-reprocessar-modal` (mesmo campo de arquivo de "Nova Análise", reaproveitando `wireArquivoField()` que já era genérico o bastante) — `pidReprocessarApuracaoContabil(id, formData)` chama o endpoint, atualiza a lista e, se a apuração reprocessada é a que já está aberta na revisão, também re-renderiza a tela (`apuracaoAtual = atualizada; renderRevisao()`). Cada uma das 3 tabelas (Balancete/DRE/Análise Vertical) ganhou um badge de alerta (`pidDcAlteradaBadgeHtml()`, ícone de triângulo) ao lado do botão de observação, visível enquanto `alterada_reprocessamento` for `True`. **Marcar a conta/linha como validada de novo NÃO limpa o alerta** (pedido explícito do usuário, revertendo a primeira versão desta rodada, que limpava — "quando o usuário marcar como validado uma conta que foi reprocessada, não deve sumir o ícone de aviso, mas sim, ficar verde... conseguimos verificar quais itens foram reprocessados e revalidados") — o badge muda de cor conforme `validado` da própria conta/linha: `--danger` (vermelho) enquanto pendente, verde (`--validada`, mesmo hex de `.status-pill--ativo`) depois de validado; `pidAtualizarValidadoContaContabil()`/`...LinhaDreContabil()`/`...LinhaAnaliseVerticalContabil()` voltaram a enviar só `{ validado }`, sem tocar em `alterada_reprocessamento`. O campo só é limpo de verdade num próximo reprocessamento sem mudança naquela conta/linha específica (`_contabil_sincroniza_*`, backend). **Tooltip do badge mostra o valor de antes do reprocessamento**: Balancete/DRE formatam `valor_anterior_reprocessamento` direto com `pidDcFormatMoeda()`; Análise Vertical usa `pidDcValorAnteriorAvTexto()`, que junta o valor+percentual de cada mês de `valores_anterior_reprocessamento` (mesmo alinhamento posicional de `analise_vertical_meses`) numa linha por mês dentro do mesmo tooltip. Nenhum badge tem tooltip de valor quando o campo vem `null`/vazio (conta/linha nova nesta apuração, sem "antes" pra comparar). **Tooltip customizado no visual do Portal, não o balão nativo do navegador** (pedido explícito do usuário, depois de ver o balão cinza padrão do Chrome nesse badge e no de "fonte de PDF atípica" abaixo — mesmo raciocínio já aplicado a `pidConfirm()`/`pidAlert()` no lugar de `window.confirm()`/`window.alert()`, ver "Modal de confirmação genérico" no `CLAUDE.md` raiz): `pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto)` (novo, `dashboard-contabil.js`) monta um par gatilho+texto (`.dc-hover-tooltip`/`__gatilho`/`__texto`, `dashboard-contabil.css`) — mesma ideia de `.info-tooltip`/`.info-tooltip__text` (components.css), só que com `white-space: pre-line` (preserva o `\n` deliberado entre a frase e "Valor antes do reprocessamento: ..." e ainda envolve o resto normalmente) em vez de `nowrap` (que só serve pro rótulo curto "Mais informações"). Não promovido pra components.css por enquanto — só usado aqui, mas a mecânica é genérica o bastante pra virar um componente compartilhado se outro pacote precisar do mesmo tipo de tooltip mais longo. `tabindex="0"` no gatilho + `:focus-visible`/`:hover` no CSS mostram o tooltip também via teclado, não só no hover do mouse. `pidDcAlteradaBadgeHtml()`/`pidDcFontePdfAtipicaBadgeHtml()` passaram a chamar essa função em vez de escrever `title="..."` direto no `<span>`. **Bug real reportado pelo usuário logo depois: o balão saía cortado** (em cima da primeira linha da lista, e na lateral perto da borda da tabela) — `.dc-hover-tooltip__texto` nasceu `position:absolute`, mas os badges deste pacote vivem dentro de `.pa-table-wrap` (perfis-acesso.css), que tem `overflow:hidden` pra arredondar o canto da tabela; qualquer coisa `absolute` que escape da própria tabela é cortada por esse `overflow`. Corrigido trocando pra `position:fixed` (relativo à viewport, não mais preso pelo `overflow` de nenhum ancestral) com `top`/`left` calculados em JS (`pidDcPosicionaTooltip()`, topo de `dashboard-contabil.js`) no `mouseenter`/`focus` do gatilho — centralizado acima por padrão, desce pra baixo quando não tem espaço acima (badge perto do topo da tela), clampado nas duas laterais pra nunca vazar da tela (badge perto da borda da tabela). Delegado no `document` com `capture:true` (`mouseenter`/`focus` não borbulham) — um único par de listeners, registrado uma vez no carregamento do script, cobre todo badge atual e futuro, mesmo os recriados a cada re-render de `renderContas()`/`renderDre()`/`renderAnaliseVertical()`/`carregarLista()`. **Validado com os 3 testes reais**: (1) reprocessar com o **mesmo** arquivo duas vezes seguidas — `validado`/`observacao` de conta/DRE/Análise Vertical preservados, `alterada_reprocessamento` continua `False` em tudo, contagem de linhas idêntica; (2) tentar reprocessar com o arquivo de **outra empresa** — 400 com mensagem explicando a diferença de empresa/competência; (3) tentar reprocessar uma apuração **Concluída** — 400 bloqueado por `_contabil_garante_em_revisao()`. As 4 funções de sincronização também foram testadas isoladamente (dry-run com `transaction.atomic()` + rollback forçado, dados fabricados cobrindo conta/linha inalterada, alterada, nova e removida, mais achado que continua disparando e achado que para de disparar) — todos os casos bateram com o comportamento esperado antes de considerar a implementação pronta. Nenhum resíduo deixado em produção. **Log de reprocessamentos (rodada 142)**: pedido explícito do usuário — "identificar quem reprocessou e quantos reprocessamentos já ocorreu". Model novo `ContabilApuracaoReprocessamento` (`related_name="reprocessamentos"`, `reprocessado_por` FK `SET_NULL`, `reprocessado_em` `auto_now_add`, migração `0075`) — mesmo espírito de `ContabilObservacaoEdicao`, um registro por chamada bem-sucedida. `reprocessar()` cria o registro **dentro** do `with transaction.atomic()`, logo depois de `_contabil_recria_achados()` — uma tentativa que falhar no meio do caminho (PDF de empresa errada, erro de sincronização) não deixa um registro órfão, já que a transação inteira reverte junto. **Achados são recriados do zero a cada reprocessamento (rodada 143, revisão da decisão da rodada 124)**: pedido explícito do usuário — "os apontamentos que são realizados pela própria aplicação e constam na tela de Observações devem ser refeitos [a cada reprocessamento]. Isso já ressalta para o usuário que não há mais inconsistências para validar e pode verificar apenas se surgiram novos. As observações e comentários que ele realizou nas contas devem permanecer independente do reprocessamento." Reverte por completo a decisão da rodada 124 de nunca apagar/sobrescrever `status`/`observacao_contador` de achado — o comportamento anterior deixava um apontamento "tratado" pendurado na aba Observações mesmo depois de a inconsistência sumir dos dados novos, contrariando o próprio propósito de sinalizar o que ainda precisa de atenção. `_contabil_sincroniza_achados()` virou `_contabil_recria_achados()` (`views.py`) — em vez de casar por `(regra, código da conta)` e atualizar/preservar campo a campo, agora é `apuracao.achados.all().delete()` seguido de `ContabilAchado.objects.bulk_create(...)` com a lista fresca de `resultado.achados`, **exatamente o mesmo bloco de `create()`** (só que sobre uma apuração já existente). Todo achado nasce `pendente`, mesmo que a mesma combinação `(regra, conta)` já estivesse `tratado`/`ignorado` com uma justificativa escrita antes — a justificativa antiga não é preservada em lugar nenhum, some junto com o achado antigo (decisão explícita: os valores mudaram, então a tratativa escrita sobre os valores antigos não é mais válida). **Por que isso não quebra a FK de conta/observação**: a preocupação original da rodada 124 (delete+recria quebraria a FK `ContabilAchado.conta` de um achado preservado) não se aplica mais porque não há mais achado "preservado" através do delete — todo achado é recriado do zero, então a FK é sempre resolvida fresca contra `contas_por_codigo` (o mapa de contas **já sincronizadas** por `_contabil_sincroniza_contas()`, que roda antes na mesma transação). `ContabilObservacao` (comentário do contador na conta, pedido explícito do usuário pra continuar intocado) nunca teve relação nenhuma com `ContabilAchado` — é casada por chave natural (empresa+conta), vive num model totalmente separado, e `_contabil_recria_achados()` nem toca nela. Validado com um script Python isolado (`transaction.atomic()` com rollback forçado, contra uma apuração real já em produção): um achado marcado manualmente como `tratado` com justificativa, seguido de uma chamada simulando `_contabil_recria_achados()` com a mesma lista de achados detectados — o achado tratado desaparece e um novo `pendente` idêntico em conteúdo (mesma regra/conta/mensagem) toma o lugar, com `id` diferente; nada persistido além da migração já aplicada em rodadas anteriores. `ContabilApuracaoListSerializer` ganhou `reprocessamentos` (nested, via `ContabilApuracaoReprocessamentoSerializer` — só `id`/`reprocessado_por_nome`/`reprocessado_em`) — não um campo `Detail`, porque o único botão "Reprocessar" da tela vive na **lista** (`data-dc-reprocessar-abrir`, ao lado de "Abrir"), nunca dentro da revisão já aberta; `get_queryset()` ganhou `prefetch_related("reprocessamentos__reprocessado_por")` só pra `action == "list"`, evitando N+1 por apuração (mesmo cuidado que `total_achados_pendentes` já deveria ter tido, mas não tinha — não corrigido aqui, fora do escopo do pedido). **Frontend**: `abrirReprocessarModal(id)` ganhou `renderReprocessarLog(id)`, que procura a apuração em `dcListaApuracoes` (já carregada, sem chamada de API própria) e preenche uma seção nova dentro do próprio `#dc-reprocessar-modal` (`#dc-reprocessar-log`, nasce `hidden` — não aparece nada numa apuração nunca reprocessada) com a contagem (`.dc-reprocessar-log__count`, mesmo padrão visual de `.dc-obs-resumo__count`) e a lista "Nome · dd/mm/aaaa hh:mm" (mais recente primeiro, ordem que já vem do `Meta.ordering` do model). `pidDcFormatDataHora()` (novo, ao lado de `pidDcFormatData()`) é o primeiro formatador de data+hora deste arquivo — os demais (assinatura de observação, "Criado em" da lista) só mostram a data, sem hora; aqui a hora importa porque mais de um reprocessamento pode acontecer no mesmo dia. Como `carregarLista()` já roda depois de um reprocessamento bem-sucedido (pra atualizar a linha da tabela), o log fica correto da próxima vez que o modal for aberto pra mesma apuração, sem nenhuma chamada extra. Validado com um script Python ad-hoc (dentro de `transaction.atomic()` com rollback forçado, contra uma apuração real já em produção): dois registros criados, serializados na ordem certa (mais recente primeiro) e com `reprocessado_por_nome` nulo tratado como esperado; nada persistido. ### PDF de fonte atípica — título de seção sem acento + aviso ao contador (rodada 125) **Bug real, encontrado com um PDF de cliente novo** (`1751 - Balancete 07.2026.pdf`, TAROBA INDUSTRIA HOTELEIRA LTDA): `POST /api/contabil-apuracoes/` devolvia 400 "Nenhuma linha de DRE encontrada no PDF" — o `codigo_empresa`/cabeçalho extraía normalmente, só a seção da DRE nunca era reconhecida. Causa raiz, confirmada rodando `pdfplumber` de verdade contra o arquivo (nunca supor a partir de texto colado — ver `[[feedback_pdf_parser_precisa_arquivo_real]]` na memória): a fonte embutida nesse PDF específico (instalação/versão diferente do Questor) perde o til do "Ã" ao extrair "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO" → sai "DEMONSTRAÇAO..." (só falta o til, resto do caractere sai certo — não é um replacement character). `parser.py` comparava esse título por igualdade exata (`texto.startswith(_TITULO_DRE)`), então a seção nunca era detectada. **Correção**: `_normaliza_titulo()` (novo em `parser.py`, usa `unicodedata.normalize("NFKD", ...)` + remoção de acento) compara o título **sem acento** — `_TITULO_DRE_NORM`/`_TITULO_ANALISE_VERTICAL_NORM` calculados uma vez no import do módulo. Mesmo espírito de `_RE_PERIODO` já aceitar `Per[ií]odo` pra essa mesma classe de variação de fonte entre clientes/instalações. **Segundo problema, sem correção segura**: o mesmo PDF também tem algumas descrições de conta com palavras coladas (ex. "DEPÓSITOS BANCÁRIOS A VISTA" → "...BANCÁRIOSA VISTA") — o espaçamento entre caracteres dessa fonte varia demais pra um limiar fixo de distância funcionar (`_GAP_ESPACO`, calibrado contra o PDF de referência). Investigação real, não só teórica: (1) medi a distribuição de vãos entre caracteres nesse PDF — o vão "dentro de palavra" (ex. entre "U" e "I" de "EQUIVALENTES") chega a ficar **maior** que um vão real "entre duas palavras curtas" (ex. antes de um "A" sozinho) em alguns pontos, então nenhum limiar único separa os dois casos corretamente; (2) cheguei a cogitar usar os caracteres de espaço literais do próprio PDF como sinal (esse arquivo tem bem menos espaços "de grade" que o de referência), mas descobri que a posição vertical desses espaços às vezes arredonda pra uma linha diferente da do texto real da mesma linha visual, tornando esse sinal não-confiável linha a linha. Testei baixar `_GAP_ESPACO` (ex. pra `0.3`) — conserta alguns casos mas quebra palavras que hoje saem certas (`EQUIVALENTES` vira `EQU IVALENTES`) — decisão explícita do usuário de **não** arriscar essa mudança: valores monetários nunca são afetados, só a descrição de algumas contas, então o custo de regressão supera o benefício. **Solução adotada — avisar, não tentar corrigir automaticamente** (pedido explícito do usuário, depois de rejeitar a ideia inicial de um botão de lápis pra editar a descrição manualmente — suspenso por enquanto): `ContabilApuracao.fonte_pdf_atipica` (`BooleanField`, migração `0070`) é `True` quando `_normaliza_titulo()` precisou de verdade (o título bateu sem acento mas não bateria com acento) pra reconhecer a seção DRE ou Análise Vertical — sinal indireto mas real de que este PDF usa uma fonte diferente da do relatório de referência, a mesma classe de variação que já se provou capaz de grudar palavras em descrição de conta. `ResultadoExtracao.fonte_pdf_atipica` (novo campo no dataclass, `dashboard_contabil/modelos.py`) carrega o valor calculado em `extrai_balancete_dre()`; `ContabilApuracaoViewSet.create()`/`.reprocessar()` persistem no model. Exposto em `ContabilApuracaoListSerializer`/`ContabilApuracaoDetailSerializer` (`fonte_pdf_atipica`, sem rota de escrita — `http_method_names` do ViewSet nem inclui PATCH/PUT). **Frontend**: `pidDcFontePdfAtipicaBadgeHtml()` (`dashboard-contabil.js`) — ícone de "i" (mesma forma de `PID_DC_OBSERVACAO_ICONE`, cor `--gold` pra diferenciar visualmente, os dois nunca aparecem lado a lado) ao lado do nome da empresa, tanto na linha da lista quanto no cabeçalho `#dc-review-empresa` da tela de revisão (que passou de `.textContent` pra `.innerHTML`, escapando `codigo_empresa`/`nome_empresa` manualmente com `pidDcEscapeHtml()` já que precisa comportar HTML agora). Tooltip customizado (`pidDcHoverTooltipHtml()`, ver "Tooltip customizado no visual do Portal" acima) explica o motivo e pede pra conferir os nomes de conta com atenção — puramente informativo, não bloqueia nem oculta nada. **Validado contra os 3 arquivos reais disponíveis**: `fonte_pdf_atipica` calculado `True` só pro `1751` (o PDF com o problema), `False` pros PDFs de referência já validados (`792`, `2017`) — sem falso positivo nos dois já confirmados corretos, e a extração completa do `1751` (252 contas, 231 linhas de DRE, Análise Vertical com 3 meses) bate exatamente com o total esperado depois do fix de `_normaliza_titulo()`. ### Observação virou histórico por empresa+conta (rodada 126) Pedido explícito do usuário: uma observação registrada num mês (o exemplo dele foi um ajuste de estoque) precisa reaparecer na análise do mês seguinte, assinada por quem escreveu e com a data, **bloqueada pra edição** por ser registro histórico, com três caminhos (manter o histórico — padrão; ocultar das próximas execuções; incluir uma observação nova) e filtro de visibilidade ao cliente em todas elas. Até aqui a observação era um campo da linha (`ContabilConta.observacao`/`oculta_no_relatorio` e equivalentes na DRE/Análise Vertical), então morria junto com a competência. Três decisões de escopo confirmadas por `AskUserQuestion` **antes** de implementar, todas com a opção recomendada aceita: 1. **Toda observação propaga por padrão** — não existe "fixar"; o que existe é o inverso, encerrar explicitamente. Evita histórico que só existe quando alguém lembra de marcar. 2. **O histórico cobre Balancete/D.R.E./Análise Vertical** — a justificativa de tratativa de um item de auditoria (`ContabilAchado.observacao_contador`) continua presa à apuração como sempre foi. Misturar os dois fluxos aumentaria o escopo sem ganho claro. (Nota da rodada 143: essa justificativa **não** sobrevive mais a um reprocessamento — o achado inteiro é recriado do zero nesse fluxo, ver "Achados são recriados do zero a cada reprocessamento" mais abaixo; ela só é permanente enquanto a apuração não é reprocessada, diferente de `ContabilObservacao`, que atravessa competências inteiras.) 3. **`mostrar_ao_cliente` é sempre alternável**, inclusive numa observação já travada — o bloqueio protege texto, autor e data; mostrar ou não ao cliente é decisão editorial de cada relatório, e uma marcação errada precisa ser corrigível sem reescrever o histórico. **Model `ContabilObservacao`** (`portal_api/models.py`, migração `0071`): escopo `codigo_empresa` + `alvo_tipo` (`conta`/`dre`/`analise_vertical`) + `alvo_chave`, mais `alvo_rotulo` (descrição no momento em que foi escrita, só pra exibir se aquela conta sumir do plano), `apuracao_origem` (`SET_NULL`) + `competencia_origem` (cópia, pra vigência continuar resolvendo se a apuração for excluída), `texto`, `mostrar_ao_cliente` (substitui `oculta_no_relatorio`, com o sinal invertido pra bater com o rótulo que o contador vê), `criado_por`/`criado_em` e o trio `encerrada_em_competencia`/`encerrada_por`/`encerrada_em`. **Chave natural, nunca FK pra linha**: `"codigo|descricao"` no Balancete, `"descricao|nivel"` na DRE/Análise Vertical (`chave_conta()`/`chave_linha()` no model, `_contabil_chave_alvo()` na view) — exatamente as chaves que `_contabil_sincroniza_*()` já usa no reprocessamento e que `regras.py` usa no histórico de variação. É o que faz a observação seguir a mesma conta de uma competência pra outra, e de brinde tira qualquer risco do reprocessamento sobre ela (antes era preciso garantir explicitamente que `observacao` não fosse tocada; agora ela nem mora lá). **Bug real (rodada seguinte) — observação "vazando" pra contas irmãs com a mesma classificação**: a chave do Balancete nasceu só `codigo` (sem `descricao`) — funcionava contra os balancetes de referência usados até então, mas o Questor reaproveita o mesmo código de classificação pra várias contas analíticas de mesma natureza (confirmado pelo usuário: 6 bancos diferentes — Banco do Brasil, Inter, Itaú, Mercado Pago, PagSeguro, Sicredi — todos sob o mesmo código de "Depósitos Bancários à Vista"). Como a chave não distinguia entre eles, uma observação escrita num banco aparecia em todos os outros com o mesmo código. Corrigido trocando a chave pra `"codigo|descricao"` (`ContabilObservacao.chave_conta(codigo, descricao)`, `dcChaveObsConta()` em `dashboard-contabil.js` — mesmo formato dos dois lados) e `_contabil_sincroniza_contas()` (views.py) pra casar contas por `(codigo, descricao)` em vez de só `codigo` no reprocessamento (mesmo trade-off que `_contabil_sincroniza_linhas_dre()` já aceitava: uma conta renomeada, mesmo código, vira uma conta "nova" — a antiga é excluída e outra é criada, em vez de atualizada no lugar). Migração de dados `0073` recalcula o `alvo_chave` de toda `ContabilObservacao` já gravada (`alvo_tipo="conta"`) a partir do próprio `alvo_rotulo` (a descrição da conta congelada no momento em que a observação foi escrita, já armazenada em cada registro) — não precisou reconstruir nada a partir da apuração de origem. **Vigência** (`vigentes_para()`/`vigente_em()`): aparece em toda apuração da mesma empresa com `competencia_origem <= C` e (`encerrada_em_competencia` nulo ou `>= C`). Daí saem os três caminhos pedidos: manter é não fazer nada; encerrar grava a competência aberta (a observação **continua visível nela** e some da seguinte em diante, o histórico nunca é reescrito); incluir uma nova cria outro registro, então uma conta passa a ter uma thread, não um texto único. **Imutabilidade** (`ContabilObservacaoViewSet._garante_texto_editavel()`): `texto` só é aceito enquanto a apuração de origem estiver "Em revisão"; depois disso (ou numa competência posterior, onde o frontend já mostra a observação travada) a edição é recusada com 400, pedindo pra registrar uma observação nova ou encerrar a existente. `DELETE` segue a mesma regra: histórico não se apaga, se encerra. A regra do backend olha o status da apuração de origem, e a da tela olha `historica` (calculado contra a competência aberta) — as duas convergem no uso real; a diferença só apareceria se alguém editasse pela API uma observação de um mês ainda em revisão estando com outro mês aberto na tela. **Endpoints** (mesma permissão de toggle único do resto da ferramenta): `GET /api/contabil-apuracoes/{id}/observacoes/` (recorte de vigência já serializado com `historica`/`encerrada` calculados contra a competência da apuração), `POST /api/contabil-observacoes/` (recebe `apuracao` + `alvo_tipo` + `alvo_id`, o id da conta/linha **desta** apuração — empresa, competência, chave e rótulo são derivados no servidor, mesmo espírito da `chave` derivada em `IndicadorContabilDefinicao`; alvo de outra apuração é 400), `PATCH` (aceita `texto` e/ou `mostrar_ao_cliente`, cada um com sua regra), `DELETE`, e as actions `encerrar`/`reativar` (as duas recebem a apuração aberta no corpo, já que é ela quem define a competência de corte). O viewset não tem `list`/`retrieve` de propósito: a leitura é sempre pelo recorte de vigência de uma apuração. **Relatório "Gerar Dashboard"**: `dashboard()` monta as observações vigentes com `mostrar_ao_cliente=True` uma vez e as usa em dois lugares — penduradas em cada linha da árvore (`_contabil_arvore_contexto()` ganhou os parâmetros opcionais `observacoes_por_chave`/`chave_fn`), pro ícone/painel inline por conta, e nas listas `observacoes_contas`/`observacoes_dre`/`observacoes_analise_vertical`, que agora são listas de `ContabilObservacao` (não mais de contas/linhas). Cada item mostra a assinatura e, quando a observação vem de um mês anterior, a competência em que foi registrada. **Seção "Observações do X" fica ACIMA da tabela, não abaixo** (pedido explícito do usuário, rodada seguinte, nas três abas — Balancete/D.R.E./Análise Vertical) — só trocou a ordem dos dois `.dcr-secao` dentro de cada `.dcr-tab-panel` (`dashboard-contabil-relatorio.html`), nenhuma mudança de contexto/dado. **Clicar numa observação rola até a conta/linha que ela referencia, "se houver"**: `dashboard()` calcula, pra cada observação, uma `ancora` (setada direto no objeto Python, não um campo do model — `_com_ancora()`, função local dentro de `dashboard()`) igual ao `data-dcr-id` da linha correspondente na árvore (`conta-{id}`/`linha-{id}`/`av-{id}`), casando pela mesma chave natural que `observacoes_por_tipo` já usa (`mapa_conta_por_chave`/`mapa_dre_por_chave`/`mapa_av_por_chave`, construídos a partir de `contas`/`linhas_dre`/`linhas_analise_vertical` **desta** apuração). `ancora` fica `None` quando a conta/linha não existe mais nesta apuração (observação histórica de uma conta que saiu do plano, por exemplo) — o "se houver" do pedido: a observação continua aparecendo normalmente, só sem virar link. No template, só o `<li>` com `obs.ancora` ganha `data-dcr-obs-ir="{{ obs.ancora }}"` + `role="button" tabindex="0"` + classe `.dcr-obs-item--clicavel` (cursor de ponteiro, contorno de foco); as demais continuam com a mesma aparência de sempre. `pidDcrArvore(tbodyId)` passou a **devolver** `{ expandeAte, resetar }` em vez de nada — `expandeAte(id)` sobe a cadeia de ancestrais de uma linha (`ancestrais()`, olha `data-dcr-nivel` decrescente a partir do índice da linha) e reabre (`colapsadas[id] = false` + classe `is-expanded` no botão) só os que estiverem colapsados, sem mexer em mais nada da árvore (não é um "expandir tudo", só o caminho necessário até aquela linha) — devolve o `<tr>` já visível, ou `undefined` se `id` não existir. `pidDcrObsResumo(container, arvore)` (nova função) liga o clique/Enter/Espaço num `<li data-dcr-obs-ir>` a `arvore.expandeAte()` + `scrollIntoView({block:"center"})` + um flash de destaque de 1,2s (`.dcr-row-flash`, `@keyframes dcrRowFlash`, anima o `background` do `<td>` a partir do `--dcr-row-bg` que a própria linha já tem, então funciona igual numa linha comum, total ou já destacada). Chamada 3 vezes (uma por `<ul id="dcr-obs-lista-{balancete,dre,av}">`), cada uma recebendo a `arvore` retornada pelo `pidDcrArvore()` da mesma tabela — observação e conta/linha sempre vivem na mesma aba, então nunca precisa trocar de aba pra chegar lá. Validado de duas formas: (1) ponta a ponta com `Client.force_login()` em transação com rollback forçado — observação com conta real ganhou `data-dcr-obs-ir` correto, observação "órfã" (chave sem conta correspondente nesta apuração) não ganhou âncora nem classe clicável, e as duas seções aparecem antes da tabela no HTML gerado; (2) **clique de verdade num Chromium headless (Playwright)**, contra o relatório real de uma apuração já em produção (empresa `2017`) — confirmou que `expandeAte()` de fato revela a linha e a página rola até ela, sem erro de JS. Essa segunda rodada de teste só aconteceu **depois** de o usuário reportar "não funciona" já em produção: a causa real não era um bug de código, e sim que `dashboard()` (a mudança que calcula `ancora`) mora em `views.py` — o `runserver` do usuário precisa reiniciar (ou o autoreload do Django precisa pegar a mudança) pra passar a computar isso; a reordenação de HTML (mudança só de template) já aparecia sem reiniciar nada, o que mascarou o diagnóstico por um tempo. Lição: uma mudança em `.py` exige reiniciar o `runserver` pra valer pro usuário testar; mudança só em `.html`/`.css` não exige. **Botão "Limpar formatação" (um por tabela) e "Voltar ao topo"** (pedido explícito do usuário, mesma rodada): `resetar()` (novo, dentro do fechamento de `pidDcrArvore`) devolve `colapsadas`/`destaque`/`historico` pro estado inicial do servidor (mesmo critério de `data-dcr-colapsado-padrao` usado na primeira carga) e limpa qualquer `.dcr-row-flash` que tenha sobrado; `pidDcrObs(tbodyId)` também passou a devolver `{ fecharTudo }`, que fecha todo painel de observação inline aberto na tabela. O botão (`data-dcr-reset="dcr-{balancete,dre,av}-body"`) chama os dois de uma vez, via dois `dict`s (`dcrArvores`/`dcrObsControles`) que casam o `tbodyId` do atributo com a `arvore`/painel certos — não desfaz o que está **fora** daquela tabela (cada botão só afeta a própria seção). "Voltar ao topo" (`#dcr-scroll-top-btn`, canto inferior direito, sempre presente no DOM) aparece (`.is-visible`, `opacity`/`transform` com `transition` normal — não é um elemento que entra a partir de `hidden`/`display:none`, então não precisa do padrão `animation` do resto do documento) só depois de `window.scrollY` passar de `PID_DCR_SCROLL_TOP_LIMIAR` (320px), e faz `window.scrollTo({top:0, behavior:"smooth"})` ao clicar. Os dois somem em `@media print` (`.dcr-th-reset-btn`/`.dcr-scroll-top-btn { display: none; }`), mesmo padrão de `.dcr-print-btn`/`.dcr-export-btn`. **"Limpar formatação" virou ícone dentro do cabeçalho "Observação"** (rodada seguinte, pedido explícito do usuário): nasceu como um botão com texto solto (borda, padding de pílula, `.dcr-reset-btn`) dentro do `<h2>` de cada seção — passou a ser um ícone só, dentro da própria célula `<th>Observação</th>` de cada tabela, mesmo padrão do botão "Restaurar formatação padrão" que já existe no `<thead>` de Balancete/D.R.E. da tela de revisão (`icon-btn dc-reset-formatacao-btn` dentro de `.dc-th-linha`, ver `dashboard-contabil.html`/`.css`). `.dcr-th-linha` (flex, rótulo à esquerda/ícone à direita) e `.dcr-th-reset-btn` (botão circular 20px, cor `--roxo-escuro`, sem borda) são as classes novas em `dashboard-contabil-relatorio.html`; `.dcr-reset-btn` foi removida por completo (zero consumidor restante). `data-dcr-reset` (o atributo lido pelo JS) não mudou de lugar conceitualmente — só migrou de dentro do `<h2>` pra dentro do `<th>` — então nenhuma linha de `<script>` precisou mudar. **Bug real, achado testando essa mudança na Análise Vertical**: essa tabela pode ter bem mais colunas que Balancete/D.R.E. (Descrição + 2 por mês + Observação — normalmente 8 pra 3 meses), e `.dcr-page` (container do relatório inteiro) tem `max-width:1140px`. Com `.dcr-tabela-wrap { overflow: hidden }` (valor original, pensado só pra arredondar os cantos), o navegador espremia cada coluna até quebrar o texto do cabeçalho em 2-3 linhas e cortar a última coluna (Observação, com o ícone novo) pra fora da área visível — o ícone simplesmente não aparecia. Corrigido com duas mudanças em `table.dcr-tabela`/`.dcr-tabela-wrap`: `overflow-x: auto` (era `overflow: hidden`, que zerava os dois eixos — `overflow-y` continua `hidden`, mesmo comportamento vertical de sempre) permite rolagem horizontal quando a tabela precisa de mais espaço do que o container tem; `white-space: nowrap` em `table.dcr-tabela th` impede o cabeçalho de quebrar linha, fazendo a tabela realmente crescer além do container (e então rolar) em vez de espremer as colunas até ficarem ilegíveis. Balancete/D.R.E. (poucas colunas, sempre cabiam sem aperto) não mudam de aparência com isso. **Análise Vertical: texto compacto pra caber sem rolagem (pedido explícito do usuário, mesma rodada)**: a rolagem horizontal resolvia o corte, mas o usuário preferiu texto/espaçamento menores pra caber tudo sem precisar arrastar, pelo menos no caso comum de 3 meses. Nova classe `dcr-tabela--compacta` (só no `<table>` da Análise Vertical, `dashboard-contabil-relatorio.html`): - `table.dcr-tabela.dcr-tabela--compacta th, td { padding: 5px 7px; font-size: 0.74rem; }` e `th { font-size: 0.6rem; letter-spacing: 0.01em; }` (era `9px 14px`/`0.85rem`/`0.72rem`/`0.03em`, os valores padrão que Balancete/D.R.E. continuam usando). - `.dcr-th-reset-btn`/`.dcr-toggle`/`.dcr-toggle-spacer`/`.dcr-obs-btn` ganham tamanhos menores (`16px`/`15px`/`15px`/`20px`) só dentro de `.dcr-tabela--compacta` — os ícones precisavam encolher junto com o texto, senão dominariam uma célula já estreita. - **Novo filtro `mes_curto`** (`portal_api/templatetags/contabil_extras.py`) corta o ano do mês pra 2 dígitos (`"mai/2026"` → `"mai/26"`) — usado só no cabeçalho desta tabela (`{{ mes|mes_curto|capfirst }} — Valor`/`— Variação`), já que "— Valor"/"— Variação" repetido em cada coluna por mês era o maior consumidor de largura do cabeçalho (`table.dcr-tabela th { white-space: nowrap }`, ver ajuste anterior). Não usado em nenhum outro lugar do relatório (a lista de observações, por exemplo, continua mostrando a competência por extenso via `{{ ...|competencia }}`, formato diferente — `MM/AAAA` a partir de um `date`, não de uma string "mês/ano" já formatada). `overflow-x: auto` (ajuste anterior) continua como rede de segurança — uma apuração com mais de 3 meses ainda pode precisar de rolagem horizontal, já que não há como garantir que qualquer quantidade de colunas caiba num container de largura fixa só encolhendo texto até um certo ponto (ilegibilidade é o limite). O objetivo desta mudança é só o caso comum (3 meses, o normal do PDF Questor) caber inteiro sem arrastar nada. **Frontend** (`dashboard-contabil.js`): as observações são carregadas à parte da apuração (`dcCarregarObservacoes()`, chamada ao abrir/criar uma análise) e indexadas por chave natural (`dcObsIndice`), porque não pertencem ao payload da apuração. O editor inline virou uma thread (`dcObsPainelHtml()`/`dcObsItemHtml()`): histórico em cima (autor, data, competência de origem, selos "Histórico"/"Encerrada"/"Editada"/"Aparece ao cliente"/"Interna" e ações de olho, ver histórico de edições (só quando `editada`), editar, encerrar/reativar), campo de observação nova embaixo. **Sem botão de excluir** (removido numa rodada seguinte, pedido explícito do usuário) — a exclusão de uma observação vigente fica limitada ao fluxo de "ocultar das próximas competências" (`encerrar`), nunca um apagar definitivo pela tela; o `DELETE` do backend (`ContabilObservacaoViewSet`, ver "Imutabilidade"/"Endpoints" acima) continua existindo e reachável via API, só não tem mais consumidor no frontend. Um handler único (`dcTrataCliqueObservacao()`) atende as três tabelas e as quatro listas de resumo, e cada mutação refaz o fetch e re-renderiza tudo (`dcRenderObservacoesTudo()`) — a mesma observação pode estar visível em mais de um lugar ao mesmo tempo. O botão da coluna "Observação" ganhou um contador (`.dc-obs-contador`), já que uma conta pode ter várias. Os chips "Todas / Visíveis ao cliente / Internas" (`dcObsFiltro`) existem nas quatro listas e compartilham a mesma variável: filtrar numa aba filtra em todas. **Ícone de observação da conta/linha "mãe" também se destaca quando um descendente recolhido tem observação** (mesma rodada do bug acima, pedido explícito do usuário: "caso esteja recolhida, o usuário consegue visualizar se há ou não observações realizadas"): `dcTemObservacaoDescendente(tipo, itens, nivelFn, chaveFn, id)` (nova, reaproveita `dcDescendentes()` — todos os descendentes, não só os filhos diretos) roda pra toda conta/linha sintética (`temFilhos[i]`) nas três árvores (`renderContas()`/`renderDre()`/`renderAnaliseVertical()`) e é passada como quarto argumento de `dcObsBotaoHtml()`. O ícone da própria sintética só ganha o destaque quando ela mesma **não** tem observação própria (senão prevalece o `--preenchida` de sempre) — nova classe `.dc-conta-observacao-btn--descendente` (`dashboard-contabil.css`), cor `--gold` (mesmo tom do estado "parcial" do botão "validado", pra reaproveitar um significado visual já existente de "tem algo pendente de atenção neste grupo" sem inventar uma cor nova). Clicar no ícone continua abrindo o painel da própria conta/linha (que nasce vazio nesse caso) — é só um sinal visual de "tem observação em algum lugar dentro deste grupo recolhido", não um atalho pra ela. **Coluna "Conta" (rodada seguinte)**: a aba Balancete da tela de revisão mostrava só a Classificação (`codigo`) numa coluna rotulada "Conta" — o `conta_numero` (numeração interna do Questor, já extraído pelo parser e salvo em `ContabilConta` desde sempre, ver "Models" acima) nunca tinha coluna própria, diferente do PDF original (que traz as duas: "Conta" e "S Classificação"). `dashboard-contabil.html` ganhou uma coluna nova antes da existente — hoje a tabela do Balancete tem 8 colunas: Conta (`conta_numero`) | Classificação (`codigo`) | Descrição | Saldo Anterior | Débito | Crédito | Saldo Atual | Observação. Escopo confirmado com o usuário: só a tela de revisão, não o relatório "Gerar Dashboard" (que continua só com Classificação — não precisa do número interno do Questor pro administrador da empresa). Como a posição das colunas mudou, os seletores CSS que dependiam de índice (`dashboard-contabil.css`, `.dc-contas-table td:nth-child(...)` — alinhamento numérico das colunas de valor, e o estilo apagado/`nowrap` das colunas de identificação da conta) e o colspan do editor inline de observação (`dcObsPainelHtml("conta", ...)` em `dashboard-contabil.js`, `7` → `8`) precisaram ser ajustados junto. Balancete é a única tabela com esse par Conta/Classificação — DRE e Análise Vertical não têm código de classificação nenhum (ver "Extração do PDF" acima), então não são afetadas. **Histórico de edições de texto** (`ContabilObservacaoEdicao`, migração `0072`, mesma rodada da remoção do botão de excluir): pedido explícito do usuário — sem a opção de excluir, uma edição de texto precisava deixar rastro visível, pra não virar uma forma indireta de "apagar" uma observação importante reescrevendo por cima. Um registro por `PATCH` que muda `texto` de fato (`texto_novo != observacao.texto` antes de salvar, em `ContabilObservacaoViewSet.partial_update()`) — nunca por `mostrar_ao_cliente`/`encerrar`/`reativar`, que não tocam o conteúdo, e nunca quando o texto enviado é igual ao já salvo. `ContabilObservacaoSerializer` ganhou `editada` (`bool(obj.edicoes.all())`) e `edicoes` (lista aninhada, mais recente primeiro no frontend) — os dois só existem nesse serializer (usado pela tela), o relatório HTML pro cliente (`dashboard()`) não os usa, então o selo/histórico nunca aparece lá. `ContabilApuracaoViewSet.observacoes()` ganhou `.prefetch_related("edicoes__editado_por")` pra não gerar uma query por observação. Frontend: selo `.dc-obs-selo--editada` ("Editada") ao lado dos demais, e um botão de relógio (`data-dc-obs-historico`, `PID_DC_ICON_HISTORICO`) que abre `#dc-obs-historico-modal` (`pidDcAbrirHistoricoObservacao()`) — lista texto anterior (riscado) → texto novo, autor e data de cada edição; usa `obs.edicoes` já carregado junto da observação, sem chamada de API própria. Validado ponta a ponta via `Client.force_login()` dentro de uma transação com rollback forçado: criação sem edição (`editada=False`), primeira edição real de texto cria o registro e vira `editada=True`, reenviar o mesmo texto não duplica, alternar `mostrar_ao_cliente` não gera edição, e uma segunda edição real acumula um segundo registro — nada gravado em produção. **Migração de dados**: a `0071` cria o model, copia cada observação preenchida das três tabelas (autor e data vêm da apuração, a melhor aproximação disponível — o modelo antigo não guardava nada disso por observação; `oculta_no_relatorio` vira `mostrar_ao_cliente` invertido) e só então remove os seis campos antigos. Em produção eram 3 observações (2 de conta, 1 de DRE), todas migradas e conferidas depois de aplicar. ### Ordenação e filtro por coluna no histórico (`#dc-list-table`) Pedido explícito do usuário pra ter a mesma experiência de `#ips-list-table` (Importação de Plano de Saúde) — mesmo mecanismo, client-side, portado 1:1 e renomeado com o prefixo `dc-`/`PID_DC_*` (não compartilhado entre os dois arquivos JS/CSS, cada tela carrega só o próprio): - `carregarLista()` agora só busca a API e guarda em `dcListaApuracoes` (module-scope); `renderList()` (nova, sem fetch) filtra (`dcApuracaoPassaNosFiltros`) + ordena (`dcComparaValoresLista`, numérico quando os dois valores parecem número, senão `localeCompare` pt-BR) + desenha as `<tr>` — chamada tanto por `carregarLista()` quanto por qualquer mudança de ordenação/filtro, sem precisar de um novo round-trip à API. - **Ordenação padrão**: `criado_em` decrescente (`dcListSortDir = -1`) — a última execução aparece primeiro, decisão explícita do usuário (antes disso a ordem vinha só do backend, `ContabilApuracao.Meta.ordering = ["-competencia", "-criado_em"]`, que prioriza competência antes de data de criação). - **Colunas ordenáveis** (as 6 do histórico, `data-sort` no `<th>`): `empresa` (por `codigo_empresa`), `competencia`, `observacoes` (`total_achados_pendentes`), `status`, `criado_por` (`criado_por_nome`), `criado_em`. - **Colunas com filtro "estilo Excel"** (funil no `<th>`, popup com busca + checklist, reaproveita `.checklist-*` de `components.css`): `empresa`, `competencia`, `status`, `criado_por` — deixa de fora só `observacoes` (contagem, não uma dimensão de agrupamento) e `criado_em` (data/hora de criação, granularidade fina demais pra um checklist de valores distintos fazer sentido); `competencia` entrou a pedido explícito do usuário numa rodada seguinte (nasceu só ordenável, no mesmo recorte de Importação de Plano de Saúde, que deixa `criado_em` de fora do filtro — mas lá não existe uma coluna de "período" recorrente como esta, em que várias apurações de empresas diferentes tendem a cair na mesma competência). O filtro de "empresa" indexa por `codigo_empresa` (não pelo texto combinado "código - nome" da célula), mas o popup mostra o rótulo completo (`dcLabelValorFiltroLista` busca o `nome_empresa` correspondente em `dcListaApuracoes`) pra continuar identificável; o de "competencia" indexa pela data ISO bruta e mostra o rótulo `MM/AAAA` (`pidDcFormatCompetencia`). **`valoresDistintos()` ordena pelo valor bruto** (`dcComparaValoresLista`), não pelo rótulo formatado — importa justamente pra "competencia", já que ordenar pelo rótulo "MM/AAAA" agruparia por mês antes do ano (ex.: "01/2026" antes de "12/2025"), enquanto a string ISO "AAAA-MM-DD" já ordena cronologicamente certo por comparação simples. - Botão "borracha" (`#dc-list-reset-btn`, dentro do último `<th>` vazio, ao lado da coluna de ações) limpa os quatro filtros e volta a ordenação pro padrão (`criado_em` desc) de uma vez. - Sem paginação (diferente de Importação de Plano de Saúde) — não foi pedida, e o histórico desta ferramenta tende a ser mais curto (uma linha por empresa+competência). ### Valores monetários dos achados sem formatação BR (bug real, rodada seguinte) Usuário reportou (com print da aba Observações da revisão) que os valores em R$ dentro do texto dos achados apareciam sem separador de milhar e com ponto decimal (ex.: "R$ 643545.85"), inconsistente com o padrão brasileiro (ponto de milhar, vírgula decimal) já usado em Balancete/DRE (`pidDcFormatMoeda()` no frontend) e no relatório "Gerar Dashboard" (`moeda()` em `contabil_extras.py`). Causa raiz: `regras.py` interpola `Decimal` **direto** num f-string (`f"R$ {conta.saldo_atual}"`) pra montar `AchadoDetectado.mensagem` — isso usa a formatação padrão do Python (`str(Decimal(...))`), que nunca tem separador de milhar. Corrigido com um helper novo, `_moeda(valor: Decimal) -> str` (`regras.py`), duplicado localmente em vez de importado — mesmo padrão já usado (por arquivo) em `indicadores/recibo.py`/`custo_contratacao/pdf.py`/`templatetags/contabil_extras.py`, porque este pacote é Python puro (sem tocar no ORM/app registry do Django, ver docstring do módulo). Todas as 9 mensagens de achado que interpolavam `R$ {valor}` diretamente (`regra_balanceamento_ativo_passivo`, `regra_debito_credito_divergente`, `regra_saldo_negativo_caixa`, `regra_conta_transitoria_com_saldo`, `regra_conta_deveria_zerar`, `regra_saldo_sinal_invertido` — as duas variantes —, `regra_lucro_balancete_diverge_dre`, `regra_descricao_generica`) passaram a usar `_moeda()`. **Não corrigido nesta rodada** (fora do escopo do pedido, mesma família de bug): `regra_variacao_atipica_dre` formata percentual com `f"{valor:.2f}%"` (ponto decimal, ex. "12.34%"), também inconsistente com o padrão BR (`12,34%`) — se o usuário pedir, é o mesmo tipo de ajuste. **Só vale pra achados gerados dali em diante** — um achado já persistido em produção mantém o texto antigo (sem formatação) até a apuração ser reprocessada (`reprocessar()`/`_contabil_recria_achados()` em `views.py` recria **todo** achado do zero, inclusive `mensagem`, desde a rodada 143 — ver "Achados são recriados do zero a cada reprocessamento" abaixo) ou até uma nova apuração da mesma empresa ser criada do zero; não foi escrita nenhuma migração de dados pra reformatar o texto já gravado (parsear números dentro de frase livre por regex é arriscado — o mesmo texto tem números que não são valores, como código de conta "1.01.01.001"). **Testado ponta a ponta via `Client.force_login()`** dentro de uma transação com rollback forçado: listagem por vigência, criação com chave derivada no servidor, alvo de outra apuração recusado, edição de texto, bloqueio do texto quando a apuração de origem está concluída (com a visibilidade ainda alternável nesse mesmo caso), encerrar e reativar com as fronteiras de competência conferidas nos dois sentidos, herança numa competência seguinte (observação marcada como histórica) e o relatório gerado nos dois meses, incluindo a conferência de que observação interna não vaza pro relatório do cliente. Nada gravado em produção além da própria migração.