# Relatório Contábil (Relatórios > Contabilidade)
> Este arquivo é carregado automaticamente ao trabalhar dentro de `portal_api/dashboard_contabil/`. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.).
>
> **Este arquivo descreve o estado atual, não o histórico.** Nenhuma seção aqui é datada por rodada e nenhuma narra "antes era X, agora é Y" — quando o motivo de uma decisão importa para não a reverter por engano, ele aparece como motivo, não como cronologia. O histórico rodada a rodada (92 a 148) está em `CHANGELOG.md` nesta mesma pasta. Ao implementar algo novo aqui, atualizar **os dois**: o estado atual neste arquivo, a mudança no changelog.
**Nome**: "Relatório Contábil" é o rótulo visível ao usuário; internamente tudo continua `dashboard_contabil`/`dashboard-contabil.*`/`Contabil*`/`/api/contabil-*`/`apps["dashboard-contabil"]`. O rename foi só de texto visível (menu em `catalogo.py`, `
`/`
`/cabeçalhos dos dois templates, o botão "Gerar Relatório" e os `verbose_name` do admin) — pedido explícito do usuário, para a ferramenta soar como um aliado do trabalho do contador em vez de mais um sistema. **Não propagar esse rename para dentro do código.**
Ferramenta que otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em `ITD-FISCO-7513`, "Roteiro de Conferência de Balancete"): o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor já enviado ao cliente), a ferramenta extrai as contas/linhas, roda um motor de regras de auditoria e apresenta os apontamentos numa tela de revisão, onde o contador analisa, registra observações e conclui a análise. No fim, gera um relatório HTML autocontido para o cliente.
**Permissão**: toggle único `apps["dashboard-contabil"]` em `permissoes["relatorios"]` (subgrupo "Contabilidade"), checado via `PermissaoApp("relatorios", "dashboard-contabil")` em todos os ViewSets. **Nasce restrita ao perfil "Inovação"** (override em `seed_portal.py`), já que expõe balancete/DRE completos dos clientes.
## Decisões de escopo (confirmadas com o usuário)
- **Entrada: só PDF.** O Questor também exporta Balancete/DRE em XLSX estruturado (mais confiável de extrair), levantado como alternativa e recusado pelo usuário. Não há suporte a XLSX na entrada.
- **Sempre uma apuração por vez.** Sem consolidação entre empresas ou competências — isso seria um BI à parte. O BI Contábil externo que esta ferramenta substitui tem filtros "Ano-Mês"/"Empresa-Filial" que sugerem o contrário; o escopo confirmado é uma apuração por vez.
- **O histórico para comparação mês a mês fica no próprio Portal.** Cada apuração processada fica salva (`ContabilApuracao`, chave natural `codigo_empresa`+`competencia`), e é contra ela que se compara. Não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal", que é um relatório complementar enviado ao cliente por fora, não uma entrada desta ferramenta.
- **As regras só usam o que é derivável do próprio Balancete/DRE anexado.** Nenhuma checagem do ITD que dependa de sistema externo (Questor, extratos bancários, folha, PID legado). A ferramenta é analítica, não substitui as etapas operacionais do roteiro (zeramento de saldos etc.).
- **"Auditoria de Balancetes" (Auditorias > Fisco/Contábil) é outra coisa** — placeholder `href="#"` no menu que referencia o antigo sistema PID legado. Não confundir com esta ferramenta.
## Extração do PDF (`parser.py`)
O relatório Questor tem uma particularidade de renderização que quebra a extração padrão do pdfplumber: **cada caractere é desenhado em posição própria** (sem kerning) e, por baixo, **o PDF inclui uma grade densa de caracteres de espaço cobrindo toda a largura de cada linha** — artefato do gerador, não intencional. Isso faz `page.extract_words()`/`extract_text()` tratarem esses espaços de fundo como separadores reais, quebrando números em dígitos isolados ("34.245.469,57" vira `'3'`, `'4'`, `'.'`, `'2'`...).
`_reconstroi_linhas()` contorna trabalhando direto com `page.chars`: ignora todo caractere igual a `" "` e reconstrói cada linha pela posição real (`x0`/`x1`) dos não-espaços, reinserindo um único espaço só quando o vão horizontal entre dois caracteres consecutivos passa de `_GAP_ESPACO` (0.8pt). Calibrado contra `792 - balancete 072026.pdf` (arquivo de referência, fora do repositório, em `Projetos\Balancetes`): o vão dentro de uma palavra/número é ~0, entre palavras da mesma descrição ~1.7-1.9pt, entre campos da tabela sempre ≥5pt. Validado rodando contra o PDF real antes de escrever o parser definitivo — **nunca desenhar regex só a partir de texto colado**, ver `[[feedback_pdf_parser_precisa_arquivo_real]]` na memória.
`extrai_balancete_dre(origem)` aceita caminho em disco **ou** arquivo já aberto em memória (`io.BytesIO`) — a view chama isto **antes** de salvar qualquer coisa, já que `codigo_empresa`/`competencia` só são conhecidos depois de ler o PDF.
### As três seções
- **Balancete**: cada linha casa com `_RE_LINHA_BALANCETE` (`^(conta)\s+(S)?\s*(código)\s+(resto)$`); os últimos 4 tokens monetários de `resto` (via `_RE_MONETARIO`) são Saldo Anterior/Débito/Crédito/Saldo Atual, nessa ordem, e o texto antes deles é a descrição. `tipo` é `"S"` (sintética) quando o flag aparece, `"A"` (analítica) quando não.
- **DRE**: descrição + um único valor final, sem código de classificação. `nivel` (indentação) vem do `x0` do primeiro caractere em relação ao menor `x0` da seção (raiz, nível 0), com divisor `/7.0`. `totalizador` é `True` quando algum caractere da linha usa fonte negrito (`fontname` contendo `"bold"`) — confirmado no PDF real: "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usam `Times-Bold`, as demais `Times-Roman`.
- **Demonstração Mensal (Análise Vertical)** (páginas finais, quando presentes): mesma árvore da DRE (mesma descrição/ordem/negrito), mas cada linha repete N pares "Valor Variação", um por mês mostrado (normalmente os 3 meses até a competência do PDF). **Cada valor já vem isolado por mês**, não acumulado desde janeiro como a DRE principal — confirmado comparando os dois: `linhas_dre` de julho é o YTD jan-jul, `linhas_analise_vertical` de julho é só julho. `percentual` é a análise vertical de verdade (percentual da linha sobre a Receita Operacional Bruta **daquele mês**), não uma variação mês a mês. `_RE_PAR_VALOR_VARIACAO` casa um par por vez; `_RE_MES_ANALISE_VERTICAL` captura o cabeçalho de mês uma única vez (as páginas seguintes repetem o mesmo cabeçalho, ignorado depois da primeira captura). Mesmo divisor `/7.0` da DRE para o nível.
### PDF de fonte atípica (`fonte_pdf_atipica`)
Nem todo cliente/instalação do Questor embute a mesma fonte. Num PDF real (`1751 - Balancete 07.2026.pdf`, TAROBA) a fonte perde o til do "Ã" na extração: "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO" sai "DEMONSTRAÇAO..." (só falta o til, o resto do caractere sai certo — não é um replacement character). Como o parser comparava o título por igualdade exata, a seção da DRE nunca era detectada e a criação da apuração devolvia 400 "Nenhuma linha de DRE encontrada no PDF".
`_normaliza_titulo()` compara o título **sem acento** (`unicodedata.normalize("NFKD", ...)` + remoção de acento), com `_TITULO_DRE_NORM`/`_TITULO_ANALISE_VERTICAL_NORM` calculados uma vez no import. Mesmo espírito de `_RE_PERIODO` já aceitar `Per[ií]odo` para essa mesma classe de variação.
**Problema relacionado que não tem correção segura**: o mesmo tipo de fonte também gruda palavras em descrições de conta ("DEPÓSITOS BANCÁRIOS A VISTA" → "...BANCÁRIOSA VISTA"). Investigado de verdade, não teoricamente: (1) medindo a distribuição de vãos nesse PDF, o vão *dentro* de uma palavra (entre "U" e "I" de "EQUIVALENTES") chega a ser **maior** que um vão real entre duas palavras curtas, então nenhum limiar único separa os dois casos; (2) usar os caracteres de espaço literais do próprio PDF como sinal não funciona, porque a posição vertical deles às vezes arredonda para uma linha diferente da do texto da mesma linha visual; (3) baixar `_GAP_ESPACO` para `0.3` conserta alguns casos e quebra outros que hoje saem certos (`EQUIVALENTES` vira `EQU IVALENTES`). **Decisão explícita do usuário: não arriscar.** Valores monetários nunca são afetados, só a descrição de algumas contas, então o custo de regressão supera o benefício.
A solução adotada é **avisar, não corrigir**: `ContabilApuracao.fonte_pdf_atipica` é `True` quando `_normaliza_titulo()` precisou de verdade (o título bateu sem acento mas não bateria com acento) para reconhecer a seção DRE ou Análise Vertical — sinal indireto mas real de que o PDF usa uma fonte diferente da de referência. `ResultadoExtracao.fonte_pdf_atipica` (`modelos.py`) carrega o valor; `create()`/`reprocessar()` persistem. Exposto em leitura nos dois serializers, sem rota de escrita. O frontend mostra um badge com tooltip ao lado do nome da empresa pedindo atenção redobrada aos nomes de conta; não bloqueia nem oculta nada.
Validado contra os 3 PDFs reais disponíveis: `True` só para o `1751`, `False` para `792` e `2017` (sem falso positivo nos já confirmados corretos).
## Motor de regras de auditoria (`regras.py`)
Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]`.
**`historico` continua na assinatura de toda regra por uniformidade** (`gera_achados()` chama todas do mesmo jeito), mas **nenhuma das 9 regras atuais usa esse parâmetro**. `_contabil_monta_historico()` (em `views.py`, limitado às 2 apurações anteriores) e o parâmetro `busca_historico` de `pipeline.processa_apuracao()` continuam existindo de propósito, como ponto de extensão já desenhado — não foram arrancados só porque nada os usa hoje.
### As 9 regras
1. **`balanceamento_ativo_passivo`** (alta) — soma do grupo Ativo (`codigo="1"`) deve fechar **exatamente** com a do Passivo (`codigo="2"`, que já vem negativo no relatório). Sem tolerância de centavos.
2. **`debito_credito_divergente`** (alta) — soma de Débito das contas-raiz (`codigo` sem ponto, ou seja só "1" e "2") deve bater **exatamente** com a soma de Crédito. **Não é uma checagem trivial**: como a DRE não tem colunas de débito/crédito próprias neste relatório (só um valor líquido por linha), a identidade só fecha porque a movimentação de Resultado transita pelas contas de Patrimônio Líquido do Passivo ("LUCROS/PREJUÍZOS DO EXERCÍCIO") — confirmado empiricamente contra o PDF de referência (débito total = crédito total = R$ 416.271.243,32 nas contas-raiz).
3. **`saldo_negativo_caixa`** (alta) — conta com `codigo` começando em `1.01.01.001` (grupo Caixa) e `saldo_atual < 0`.
4. **`saldo_sinal_invertido`** (média) — conta analítica (`tipo="A"`) do Ativo com saldo credor, ou do Passivo com saldo devedor. Duas exceções: conta redutora (descrição começando com `"(-)"`, que é esperado ter o sinal oposto ao grupo) e **toda conta descendente de uma redutora** — ver abaixo.
5. **`lucro_balancete_diverge_dre`** (alta) — o resultado do exercício (lucro **ou** prejuízo) precisa ser o mesmo no Balancete e na DRE, sem tolerância. Lê `CODIGO_LUCRO_PREJUIZO_EXERCICIO` (`"2.04.13.002"`, código fixo da linha sintética "LUCROS/PREJUÍZOS DO EXERCÍCIO" dentro do PL; agrega "LUCROS DO EXERCÍCIO" ou "(-) PREJUÍZOS DO EXERCÍCIO" conforme o resultado), negado (convenção de Passivo/PL com sinal invertido) contra `linhas_dre[-1].valor`. Validado batendo exato contra os 2 balancetes reais antes de entrar em produção.
6. **`conta_transitoria_com_saldo`** (média) — descrição contém "TRANSIT" com `saldo_atual != 0`, **exceto** a palavra isolada "TRANSITO" (`\bTRANSITO\b`, "dinheiro em trânsito", conceito diferente). A exclusão é por palavra isolada e não por um trecho positivo mais longo como "TRANSITOR" porque, no PDF `1751`, a fonte corrompe o acento de "TRANSITÓRIA" num caractere não recuperável — "TRANSITOR" nunca casaria com essa conta, que tem saldo real.
7. **`conta_deveria_zerar`** (média) — `TRECHOS_CONTA_DEVERIA_ZERAR`, lista curta e deliberadamente restrita: só "ADIANTAMENTOS DE SALÁRIOS", que o ITD confirma dever ficar zerada todo mês. **Não** inclui "Adiantamento de Férias"/"13º Salário", que legitimamente carregam saldo entre meses.
8. **`descricao_generica`** (baixa) — descrição exatamente `"DIVERSOS"` com saldo relevante (o ITD cita esse caso: "o contador deverá realocar estes lançamentos a conta pertinente").
9. **`variacao_atipica_dre`** (baixa) — usa a seção "Demonstração Mensal (Análise Vertical)" do próprio PDF (`atual.linhas_analise_vertical`), não o histórico do Portal. Compara os **2 meses mais recentes** dessa tabela pelo `percentual` de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos `VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO` (**1 ponto percentual** — piso para não disparar em saltos percentualmente grandes só porque a base já era perto de zero) **e**, quando o percentual anterior não é zero, `VARIACAO_LIMIAR_PERCENTUAL` (**65%** de variação relativa). Roda mesmo na 1ª apuração de uma empresa, desde que o PDF traga a seção.
**Por que não existe uma regra de variação sobre o Balancete**: a Análise Vertical do PDF só cobre linhas da DRE. A alternativa (comparar saldo de conta contra a apuração anterior, via `historico`) existiu e foi removida a pedido do usuário, junto de uma regra de razão Custos/Receita — a granularidade de `variacao_atipica_dre` sobre a Análise Vertical já cobre qualquer linha da DRE sem precisar de regra dedicada.
### Descendente de conta redutora não dispara `saldo_sinal_invertido`
Se a conta "mãe" (sintética, em **qualquer** nível acima, não só o pai direto) começa com `"(-)"`, o sinal "invertido" dos analíticos dentro dela é o comportamento esperado daquela natureza de conta, não uma inconsistência. Caso real: duas analíticas dentro de `2.04.01.003` `"(-) CAPITALA INTEGRALIZAR"` (sic, espaço grudado — a mesma classe de artefato descrita em "PDF de fonte atípica") herdam o sinal devedor esperado, mas não têm `"(-)"` na própria descrição.
`_indices_descendentes_de_conta_redutora(contas)` calcula, para a árvore inteira de uma vez, quais índices têm **algum** ancestral redutora, usando a pilha de níveis (`codigo.count(".")` + ordem de leitura do PDF) — **não** comparação de prefixo de código. O motivo é importante: o Questor reaproveita o mesmo código de classificação entre contas analíticas irmãs (ver também `ContabilObservacao.chave_conta()` abaixo), então string matching por código não é confiável para achar o pai; a pilha de níveis segue a profundidade real da árvore impressa, e funciona mesmo com códigos repetidos entre irmãos.
Validado de duas formas: árvore sintética reproduzindo o caso do usuário (conta "(-) LUCROS DISTRIBUÍDOS" com 2 sócios dentro), confirmando que o achado deixa de ser gerado **e que seria gerado sem a correção**; e rodando contra as 3 apurações reais em produção, com 32 contas identificadas como descendente de redutora (a maioria depreciação acumulada, `1.02.05.007.*`), sem falso positivo aparente.
### Formatação de valores nas mensagens
`_moeda(valor: Decimal) -> str` (local em `regras.py`, duplicado em vez de importado — este pacote é Python puro, sem tocar no ORM/app registry; mesmo padrão por arquivo de `indicadores/recibo.py`/`custo_contratacao/pdf.py`/`templatetags/contabil_extras.py`). Todas as mensagens de achado que interpolam um valor usam `_moeda()`, nunca `f"R$ {valor}"` direto (que usa `str(Decimal(...))` e nunca tem separador de milhar).
**Pendência conhecida**: `regra_variacao_atipica_dre` ainda formata percentual com `f"{valor:.2f}%"` (ponto decimal, "12.34%"), inconsistente com o padrão BR. Mesmo tipo de ajuste, ainda não pedido.
**Mensagem de achado é congelada no momento em que o achado é criado** — um achado já persistido mantém o texto com que nasceu até a apuração ser reprocessada (o que recria todo achado do zero) ou até uma apuração nova da mesma empresa ser criada. Não existe migração de dados reformatando texto já gravado: parsear números dentro de frase livre por regex é arriscado, já que o mesmo texto tem números que não são valores (código de conta "1.01.01.001").
## Models (`portal_api/models.py`)
Padrão cabeçalho → linhas de detalhe → apontamentos, mesma filosofia de `IndicadorApuracao`/`IndicadorApuracaoColaborador`.
### Apuração e linhas
- **`ContabilApuracao`** — `codigo_empresa`/`nome_empresa`/`cnpj`/`competencia` (extraídos do PDF, não informados no upload) + `periodo_inicio`/`periodo_fim` + `arquivo` + `status` (`revisao`/`concluida`). `unique_together` em `codigo_empresa`+`competencia`. Mais: `analise_vertical_meses` (`JSONField`, ex. `["mai/2026", "jun/2026", "jul/2026"]`, lista compartilhada por toda a apuração), `fonte_pdf_atipica`, `resumo_fechamento` (texto rico do contador), `indicadores_ocultos` e `indicadores_selecionados` (`JSONField`, listas de chave de indicador).
- **`ContabilConta`** — uma linha do Balancete. `conta_numero` (numeração interna do Questor) e `codigo` (classificação) são coisas diferentes, as duas extraídas. `validado` (`BooleanField`, checkbox informativo de "já conferi", sem gate em nada), `alterada_reprocessamento` + `valor_anterior_reprocessamento`.
- **`ContabilLinhaDre`** — uma linha da DRE, sem código de classificação. Mesmos `validado`/`alterada_reprocessamento`/`valor_anterior_reprocessamento`.
- **`ContabilLinhaAnaliseVertical`** — mesma árvore/descrição/nível da DRE, mas `valores` (`JSONField`) guarda um `{"valor": "...", "percentual": "..."}` **por mês**, gravado como **texto, não float**, para não perder precisão; alinhado por posição com `ContabilApuracao.analise_vertical_meses`. `valores_anterior_reprocessamento` tem o mesmo formato (não existe um valor único aqui). Lista vazia quando o PDF não traz a seção — relatório antigo ou empresa sem essa seção habilitada no Questor; a aba correspondente some nesse caso.
As três têm os mesmos recursos por linha: observação (via `ContabilObservacao`), tri-state de "validado" e ocultar do relatório.
### Apontamentos de auditoria
- **`ContabilAchado`** — nasce automático em `create()`, muda de `status` (`pendente`/`tratado`/`ignorado`) via `ContabilAchadoViewSet`, sempre com `observacao_contador` obrigatória ao sair de pendente. **Um reprocessamento apaga e recria todos os achados do zero** (ver "Reprocessar" abaixo). Dois campos de alvo, mutuamente exclusivos e os dois opcionais: `conta` (FK para `ContabilConta`) e `linha_analise_vertical` (FK para `ContabilLinhaAnaliseVertical`). As duas regras gerais (balanceamento e débito/crédito) não têm alvo nenhum.
> **"Achado" nunca aparece em texto visível ao usuário** — pedido explícito. Na UI a aba se chama "Observações" e os textos falam em "observação"/"apontamento". `achado`/`ContabilAchado`/`achados_com_observacao` continuam normais como nome de model/variável/classe CSS. Ver `[[feedback_nunca_achado_em_texto_visivel]]` na memória.
### Observações (`ContabilObservacao`)
Uma observação **não** é campo da linha: é um registro próprio, escopado por `codigo_empresa` + `alvo_tipo` (`conta`/`dre`/`analise_vertical`) + `alvo_chave`, que atravessa competências. Ver a seção "Observações" abaixo para vigência/imutabilidade. Campos: `alvo_rotulo` (descrição congelada no momento em que foi escrita, para exibir se a conta sumir do plano), `apuracao_origem` (`SET_NULL`) + `competencia_origem` (cópia, para a vigência continuar resolvendo se a apuração for excluída), `texto`, `mostrar_ao_cliente`, `criado_por`/`criado_em` e o trio `encerrada_em_competencia`/`encerrada_por`/`encerrada_em`.
- **`ContabilObservacaoEdicao`** — um registro por `PATCH` que muda `texto` de fato. Nunca por `mostrar_ao_cliente`/`encerrar`/`reativar`, e nunca quando o texto enviado é igual ao já salvo.
- **`ContabilApuracaoReprocessamento`** — um registro por reprocessamento bem-sucedido (`reprocessado_por` `SET_NULL`, `reprocessado_em` `auto_now_add`). Criado **dentro** da transação do reprocessamento, então uma tentativa que falha no meio não deixa registro órfão.
### Indicadores
- **`IndicadorContabilDefinicao`** — `chave` (`SlugField` único, sempre **derivada do `nome`** na criação, nunca aceita do cliente; imutável depois, já que pode estar referenciada em `indicadores_ocultos` ou na fórmula de outro indicador), `nome`, `descricao`, `formula` (a expressão de cálculo), `formula_exibicao` (texto **livre**, sem validação nenhuma, só para exibição ao cliente), `formato` (`moeda`/`percentual`/`indice`), `icone` (`choices`, 11 opções, default `"barras"`), `grupo` (livre, só agrupa visualmente), `padrao` (`BooleanField`, default `True`), `criado_por`/`criado_em`/`atualizado_em`.
- **`IndicadorContabilComponente`** (`related_name="componentes"`) — uma peça da fórmula. `chave` é um identificador Python válido (`^[a-z][a-z0-9_]*$`, `RegexValidator`) e **não** um `SlugField` comum, porque vira nome de variável dentro da árvore `ast` do avaliador (diferente da `chave` da `Definicao`, que pode ter hífen). `tipo` é `contas`/`linha_dre`/`variacao_conta`/`indicador`/`resultado_liquido`, e só um campo de referência é preenchido conforme o tipo: `contas_codigos`, `linhas_dre_descricoes`, `indicador_referenciado`.
**Guardado por código/descrição, nunca por FK a uma linha de uma apuração específica** — a definição é genérica, reaplicada em qualquer apuração/empresa. Funciona quando a empresa usa o mesmo plano de contas e **sai errado silenciosamente se não** (mesmo risco já aceito pelos códigos fixos das regras).
## Criação de uma apuração (`ContabilApuracaoViewSet.create()`)
Diferente de `IndicadorApuracaoViewSet`/`ImportacaoPlanoSaudeViewSet` (onde a chave natural vem do formulário), aqui `codigo_empresa`/`competencia` só são conhecidos **depois** de extrair o PDF. A ordem importa:
1. Lê o arquivo inteiro para memória (`arquivo.read()`) — nada em disco ainda.
2. `dashboard_contabil_pipeline.processa_apuracao(io.BytesIO(conteudo), _contabil_monta_historico)` — extrai cabeçalho/contas/DRE/Análise Vertical e, com a empresa/competência em mãos, chama `_contabil_monta_historico()` (função injetada, consulta o ORM) e roda as regras. `ContabilExtracaoInvalidaError` vira 400.
3. Confere se já existe apuração para essa empresa+competência (`.exists()`) → 400 com mensagem específica, **antes** de qualquer escrita (evita depender do `IntegrityError` cru, que devolveria 500).
4. Só então, dentro de `transaction.atomic()`, cria a `ContabilApuracao` (grava o arquivo via `ContentFile`) + `bulk_create` de contas / linhas DRE / linhas de Análise Vertical / achados. Um `except Exception` **fora** do `with` apaga o arquivo gravado se algo falhar no meio — upload de `FileField` não é transacional.
`pipeline.processa_apuracao(origem, busca_historico)` recebe `busca_historico` como **função** (não uma lista pronta) exatamente por essa dependência: a chave de busca só existe depois da extração.
Os achados de `variacao_atipica_dre` são ligados à linha certa por **`ordem`** (a posição de leitura no PDF, já existente em `LinhaAnaliseVerticalExtraida`/`ContabilLinhaAnaliseVertical`): monta-se `av_por_ordem = {linha.ordem: linha ...}` **depois** de persistir as linhas, e resolve-se `achado.ordem_linha_analise_vertical → ContabilLinhaAnaliseVertical`. Não por descrição (ambígua entre centros de custo) nem por id (que não existe no momento em que `regras.py` roda, sendo Python puro sem ORM).
## Reprocessar
`POST /api/contabil-apuracoes/{id}/reprocessar/` (multipart `arquivo`), bloqueado por `_contabil_garante_em_revisao()` — não existe reprocessar apuração concluída. Serve para corrigir uma análise feita com o PDF errado/incompleto **sem perder o trabalho já registrado**. O PDF novo precisa ser da **mesma** empresa+competência (senão 400 — trocar de empresa é uma análise nova).
Roda o mesmo `processa_apuracao()` de `create()`, troca o `arquivo` (apagando o antigo só **depois** do commit, pelo mesmo cuidado com storage não-transacional) e delega a resincronização para funções puras com **duas estratégias opostas**:
- **`_contabil_sincroniza_contas()` / `_linhas_dre()` / `_linhas_analise_vertical()` — atualização no lugar (mesmo `id`), nunca delete+recria.** Casam cada linha extraída contra a existente por chave natural: `(codigo, descricao)` no Balancete, `(descricao, nivel)` na DRE/Análise Vertical. Casada: atualiza os campos brutos no mesmo registro (`.save()`); se algum campo relevante mudou, força `validado=False` e `alterada_reprocessamento=True`, guardando o valor de antes em `valor_anterior_reprocessamento`/`valores_anterior_reprocessamento` (sempre lido **antes** de sobrescrever); senão preserva tudo, inclusive limpando esse campo. Sem match na extração nova: cria, com os defaults de sempre. Sobra no mapa antigo: `.delete()`.
- **`_contabil_recria_achados()` — delete+recria total**, o mesmo `bulk_create` de `create()`. Todo achado nasce `pendente`, mesmo que a mesma `(regra, conta)` já estivesse tratada com justificativa escrita — a justificativa antiga some junto.
**Por que estratégias opostas**: conta/linha é dado extraído que o contador **anota** — o valor de hoje precisa ser atualizado, mas a anotação de ontem sobre a mesma conta continua valendo. Achado é um **apontamento derivado**, recalculado inteiro a cada rodada das regras: não existe "achado que não mudou", ele dispara com os dados de agora ou não dispara. Manter um achado "tratado" que já não dispara equivale a mostrar uma inconsistência que não existe mais, contrariando o propósito de sinalizar o que precisa de atenção. Decisão explícita do usuário, revertendo a escolha original de preservar tratativas.
**Isso não quebra FK nenhuma**: não há achado preservado através do delete, então `ContabilAchado.conta` é sempre resolvida fresca contra o mapa de contas **já sincronizadas** (roda antes, na mesma transação). `ContabilObservacao` nunca teve relação com `ContabilAchado` — vive em model separado, casada por chave natural, e `_contabil_recria_achados()` nem a toca. A imunidade da observação ao reprocessamento é de graça: ela não mora na linha.
**Marcar como validada de novo NÃO limpa o alerta de alteração** (pedido explícito): o badge muda de cor conforme `validado` — `--danger` enquanto pendente, verde depois de validado — para dar para ver quais itens foram reprocessados **e** revalidados. `alterada_reprocessamento` só é limpo de verdade num próximo reprocessamento em que aquela conta/linha não mudar.
## Observações: histórico por empresa+conta
Uma observação registrada num mês reaparece na análise dos meses seguintes, assinada e datada, **bloqueada para edição** por ser registro histórico. Três decisões de escopo, confirmadas antes de implementar:
1. **Toda observação propaga por padrão.** Não existe "fixar"; existe o inverso, encerrar explicitamente. Evita histórico que só existe quando alguém lembra de marcar.
2. **O histórico cobre Balancete/D.R.E./Análise Vertical.** A justificativa de tratativa de um achado (`ContabilAchado.observacao_contador`) continua presa à apuração — e, como o achado é recriado a cada reprocessamento, ela nem sobrevive a isso. Só `ContabilObservacao` atravessa competências.
3. **`mostrar_ao_cliente` é sempre alternável**, inclusive numa observação já travada. O bloqueio protege texto, autor e data; mostrar ou não ao cliente é decisão editorial de cada relatório, e uma marcação errada precisa ser corrigível sem reescrever o histórico.
**Chave natural, nunca FK para a linha**: `"codigo|descricao"` no Balancete, `"descricao|nivel"` na DRE/Análise Vertical (`chave_conta()`/`chave_linha()` no model, `_contabil_chave_alvo()` na view, `dcChaveObsConta()` no JS — mesmo formato nos três). São exatamente as chaves que a sincronização do reprocessamento usa.
> **A descrição faz parte da chave do Balancete, e isso não é redundância.** O Questor reaproveita o mesmo código de classificação para várias contas analíticas de mesma natureza — confirmado pelo usuário com 6 bancos diferentes (Banco do Brasil, Inter, Itaú, Mercado Pago, PagSeguro, Sicredi) sob o mesmo código de "Depósitos Bancários à Vista". Com a chave só por `codigo`, uma observação escrita num banco aparecia em todos os outros. Trade-off aceito: uma conta **renomeada** com o mesmo código vira uma conta "nova" no reprocessamento (a antiga é excluída, outra é criada) — o mesmo trade-off que a DRE já aceitava.
**Vigência** (`vigentes_para()`/`vigente_em()`): a observação aparece em toda apuração da mesma empresa com `competencia_origem <= C` e (`encerrada_em_competencia` nulo ou `>= C`). Daí saem os três caminhos: **manter** é não fazer nada; **encerrar** grava a competência aberta (a observação continua visível nela e some da seguinte em diante — o histórico nunca é reescrito); **incluir uma nova** cria outro registro, então uma conta tem uma thread, não um texto único.
**Imutabilidade e exclusão**:
- `texto` só é aceito enquanto a apuração de origem estiver "Em revisão" (`_garante_texto_editavel()`); depois disso a edição é recusada com 400, pedindo para registrar uma nova ou encerrar a existente.
- Uma edição de texto que passa deixa rastro em `ContabilObservacaoEdicao`, exibida por um botão de relógio na thread — para editar não virar uma forma indireta de apagar uma observação importante reescrevendo por cima.
- `DELETE` é permitido, mas com **duas** travas somadas: a mesma regra de "só em revisão" **e** `criado_por_id == request.user.id` (senão `PermissionDenied`). Mostrar ou esconder o botão no frontend é só UX; o servidor confere as duas de novo.
- Excluir e encerrar convivem de propósito: excluir é definitivo e só de quem criou; encerrar é reversível e qualquer um do time pode usar.
> Os ícones de "encerrar" e "excluir" **não podem ser o mesmo desenho**. Encerrar usa um ícone de arquivo (caixa com uma linha); a lixeira (`PID_DC_ICON_LIXEIRA`) é só da exclusão de verdade. Os dois aparecem lado a lado na mesma linha de ações, e uma é reversível e a outra não.
## Indicadores
Todo indicador é um `IndicadorContabilDefinicao` no banco — **os 11 "de sistema" (ROA, ROE, Kanitz, EBIT, EBITDA, as três liquidezes, composição/grau de endividamento, IPL) também**. Não são caso especial em lugar nenhum do código: CRUD, cálculo e exibição passam pelo mesmo caminho de qualquer indicador personalizado. Qualquer contador com acesso à ferramenta pode criar/editar/excluir — não é tela administrativa restrita ao perfil "Inovação" (decisão deliberada: quem cria os indicadores é o próprio contador usando a ferramenta).
### Motor de fórmula (`formula.py`)
`avalia_formula(expressao, valores)` faz `ast.parse(expressao, mode="eval")` e anda pela árvore com um **allowlist** restrito: só `BinOp` (`+ - * /`), `UnaryOp` (`+ -`), número literal e `Name` (resolvido contra o dict `valores`). Qualquer outro nó (chamada de função, atributo, import, comparação) levanta `FormulaInvalidaError` **antes de qualquer coisa ser executada** — nunca `eval()`/`compile()` sobre texto digitado pelo contador.
Se qualquer nome referenciado valer `None`, ou a fórmula dividir por zero, o resultado inteiro é `None`. **"Indisponível" nunca vira 0** — disciplina seguida em todo o pacote. `valida_formula(expressao, chaves_disponiveis)` roda a mesma árvore com valores fictícios só para validar sintaxe/nomes na hora de salvar.
### Resolução contra uma apuração (`views.py`)
- `_ContabilDadosIndicadores` (dataclass) + `_contabil_coleta_dados_indicadores(apuracao)` juntam `contas_atuais`/`dre_atual`/`resultado_liquido`/`historico_completo` e derivam `contas_anteriores` (só a apuração anterior imediata). Reaproveitado por todos os caminhos de cálculo, evitando duas idas ao banco pelos mesmos dados.
- `_contabil_resolve_componente_personalizado()` resolve **um** componente: `contas`/`variacao_conta` somam em **valor absoluto** (Passivo/PL vêm negativos no relatório); `linha_dre` soma com o sinal já impresso (a DRE não segue essa convenção); `variacao_conta` é `soma_atual − soma_anterior`, `None` sem apuração anterior; `indicador` faz `valores.get(chave)`; `resultado_liquido` resolve sempre para `dados.resultado_liquido`.
- `_contabil_calcula_indicadores_personalizados()` resolve **todas** as definições. **Iterativo, não ordenação topológica de verdade**: a cada rodada calcula todo indicador cujos componentes `tipo="indicador"` já têm valor, repetindo até não sobrar progresso. Cobre encadeamento sem ordenar dependências explicitamente. Um indicador cuja dependência nunca resolve (referência quebrada ou **ciclo** entre dois personalizados) fica `None` para sempre, **sem lançar erro** — uma fórmula mal configurada não pode derrubar o relatório inteiro.
> **Por que existe o tipo `resultado_liquido`** em vez de um componente `linha_dre` apontando para a última linha: a última linha da DRE **muda de rótulo conforme o sinal do resultado** — uma empresa com prejuízo termina em "(=) PREJUÍZO LÍQUIDO DO EXERCÍCIO", com lucro terminaria em "(=) LUCRO LÍQUIDO DO EXERCÍCIO". Um `linha_dre` (que casa por texto exato) quebraria assim que o resultado de uma empresa virasse de sinal entre uma competência e outra. `resultado_liquido` resolve por **posição**, não por texto.
### Padrão vs. não padrão
`padrao=True` (default) faz o indicador aparecer em toda apuração. `padrao=False` deixa o indicador salvo e editável, mas ele só aparece numa apuração em que sua chave esteja em `ContabilApuracao.indicadores_selecionados`. Um indicador não padrão não selecionado **não aparece em lugar nenhum** daquela apuração (nem relatório, nem aba "Dashboard"), mas continua na listagem completa do hub "Gerenciar Indicadores", que nunca é filtrada por apuração.
`indicadores_ocultos` é ortogonal: esconde do **relatório do cliente** um indicador que está aparecendo.
### Exclusão de definição
`perform_destroy()` bloqueia (400) se outra definição referencia esta pela fórmula, listando os nomes dependentes — para não deixar fórmula alheia quebrada em silêncio. Depois de excluir, **limpa a chave de toda `ContabilApuracao.indicadores_selecionados`/`indicadores_ocultos` que a referenciava**: sem essa limpeza a chave fica órfã e, como os dois serializers validam a lista **inteira** a cada alternância de checkbox, o usuário fica travado sem conseguir alternar nenhum indicador naquela apuração — não só o excluído. As duas validações também descartam chave inexistente em silêncio, como rede de segurança (é só estado de exibição, não dado auditado).
### Fórmula de cálculo vs. fórmula exibida
`formula` é a expressão validada, usada só para calcular, e mostra as chaves internas dos componentes (`resultado_liquido - despesas_financeiras`). `formula_exibicao` é texto livre, nunca passa por `avalia_formula()`/`ast`, e é o que o cliente vê. No modal, os campos são rotulados "Fórmula (cálculo interno)" e "Fórmula (como aparece ao cliente)". O servidor resolve o fallback: `definicao.formula_exibicao.strip() or definicao.formula` — indicador sem a preferência preenchida mostra a fórmula técnica, nunca fica sem fórmula nenhuma.
### Ícones: duas cópias mantidas à mão
O miolo `