# 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`, ``/`<h1>`/cabeçalhos dos dois templates, o botão "Gerar Relatório" e os `verbose_name` do admin) — pedido explícito do usuário, para a ferramenta soar como um aliado do trabalho do contador em vez de mais um sistema. **Não propagar esse rename para dentro do código.** Ferramenta que otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em `ITD-FISCO-7513`, "Roteiro de Conferência de Balancete"): o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor já enviado ao cliente), a ferramenta extrai as contas/linhas, roda um motor de regras de auditoria e apresenta os apontamentos numa tela de revisão, onde o contador analisa, registra observações e conclui a análise. No fim, gera um relatório HTML autocontido para o cliente. **Permissão**: toggle único `apps["dashboard-contabil"]` em `permissoes["relatorios"]` (subgrupo "Contabilidade"), checado via `PermissaoApp("relatorios", "dashboard-contabil")` em todos os ViewSets. **Nasce restrita ao perfil "Inovação"** (override em `seed_portal.py`), já que expõe balancete/DRE completos dos clientes. ## Decisões de escopo (confirmadas com o usuário) - **Entrada: só PDF.** O Questor também exporta Balancete/DRE em XLSX estruturado (mais confiável de extrair), levantado como alternativa e recusado pelo usuário. Não há suporte a XLSX na entrada. - **Sempre uma apuração por vez.** Sem consolidação entre empresas ou competências — isso seria um BI à parte. O BI Contábil externo que esta ferramenta substitui tem filtros "Ano-Mês"/"Empresa-Filial" que sugerem o contrário; o escopo confirmado é uma apuração por vez. - **O histórico para comparação mês a mês fica no próprio Portal.** Cada apuração processada fica salva (`ContabilApuracao`, chave natural `codigo_empresa`+`competencia`), e é contra ela que se compara. Não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal", que é um relatório complementar enviado ao cliente por fora, não uma entrada desta ferramenta. - **As regras só usam o que é derivável do próprio Balancete/DRE anexado.** Nenhuma checagem do ITD que dependa de sistema externo (Questor, extratos bancários, folha, PID legado). A ferramenta é analítica, não substitui as etapas operacionais do roteiro (zeramento de saldos etc.). - **"Auditoria de Balancetes" (Auditorias > Fisco/Contábil) é outra coisa** — placeholder `href="#"` no menu que referencia o antigo sistema PID legado. Não confundir com esta ferramenta. ## Extração do PDF (`parser.py`) O relatório Questor tem uma particularidade de renderização que quebra a extração padrão do pdfplumber: **cada caractere é desenhado em posição própria** (sem kerning) e, por baixo, **o PDF inclui uma grade densa de caracteres de espaço cobrindo toda a largura de cada linha** — artefato do gerador, não intencional. Isso faz `page.extract_words()`/`extract_text()` tratarem esses espaços de fundo como separadores reais, quebrando números em dígitos isolados ("34.245.469,57" vira `'3'`, `'4'`, `'.'`, `'2'`...). `_reconstroi_linhas()` contorna trabalhando direto com `page.chars`: ignora todo caractere igual a `" "` e reconstrói cada linha pela posição real (`x0`/`x1`) dos não-espaços, reinserindo um único espaço só quando o vão horizontal entre dois caracteres consecutivos passa de `_GAP_ESPACO` (0.8pt). Calibrado contra `792 - balancete 072026.pdf` (arquivo de referência, fora do repositório, em `Projetos\Balancetes`): o vão dentro de uma palavra/número é ~0, entre palavras da mesma descrição ~1.7-1.9pt, entre campos da tabela sempre ≥5pt. Validado rodando contra o PDF real antes de escrever o parser definitivo — **nunca desenhar regex só a partir de texto colado**, ver `[[feedback_pdf_parser_precisa_arquivo_real]]` na memória. `extrai_balancete_dre(origem)` aceita caminho em disco **ou** arquivo já aberto em memória (`io.BytesIO`) — a view chama isto **antes** de salvar qualquer coisa, já que `codigo_empresa`/`competencia` só são conhecidos depois de ler o PDF. ### As três seções - **Balancete**: cada linha casa com `_RE_LINHA_BALANCETE` (`^(conta)\s+(S)?\s*(código)\s+(resto)$`); os últimos 4 tokens monetários de `resto` (via `_RE_MONETARIO`) são Saldo Anterior/Débito/Crédito/Saldo Atual, nessa ordem, e o texto antes deles é a descrição. `tipo` é `"S"` (sintética) quando o flag aparece, `"A"` (analítica) quando não. - **DRE**: descrição + um único valor final, sem código de classificação. `nivel` (indentação) vem do `x0` do primeiro caractere em relação ao menor `x0` da seção (raiz, nível 0), com divisor `/7.0`. `totalizador` é `True` quando algum caractere da linha usa fonte negrito (`fontname` contendo `"bold"`) — confirmado no PDF real: "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usam `Times-Bold`, as demais `Times-Roman`. - **Demonstração Mensal (Análise Vertical)** (páginas finais, quando presentes): mesma árvore da DRE (mesma descrição/ordem/negrito), mas cada linha repete N pares "Valor Variação", um por mês mostrado (normalmente os 3 meses até a competência do PDF). **Cada valor já vem isolado por mês**, não acumulado desde janeiro como a DRE principal — confirmado comparando os dois: `linhas_dre` de julho é o YTD jan-jul, `linhas_analise_vertical` de julho é só julho. `percentual` é a análise vertical de verdade (percentual da linha sobre a Receita Operacional Bruta **daquele mês**), não uma variação mês a mês. `_RE_PAR_VALOR_VARIACAO` casa um par por vez; `_RE_MES_ANALISE_VERTICAL` captura o cabeçalho de mês uma única vez (as páginas seguintes repetem o mesmo cabeçalho, ignorado depois da primeira captura). Mesmo divisor `/7.0` da DRE para o nível. ### PDF de fonte atípica (`fonte_pdf_atipica`) Nem todo cliente/instalação do Questor embute a mesma fonte. Num PDF real (`1751 - Balancete 07.2026.pdf`, TAROBA) a fonte perde o til do "Ã" na extração: "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO" sai "DEMONSTRAÇAO..." (só falta o til, o resto do caractere sai certo — não é um replacement character). Como o parser comparava o título por igualdade exata, a seção da DRE nunca era detectada e a criação da apuração devolvia 400 "Nenhuma linha de DRE encontrada no PDF". `_normaliza_titulo()` compara o título **sem acento** (`unicodedata.normalize("NFKD", ...)` + remoção de acento), com `_TITULO_DRE_NORM`/`_TITULO_ANALISE_VERTICAL_NORM` calculados uma vez no import. Mesmo espírito de `_RE_PERIODO` já aceitar `Per[ií]odo` para essa mesma classe de variação. **Problema relacionado que não tem correção segura**: o mesmo tipo de fonte também gruda palavras em descrições de conta ("DEPÓSITOS BANCÁRIOS A VISTA" → "...BANCÁRIOSA VISTA"). Investigado de verdade, não teoricamente: (1) medindo a distribuição de vãos nesse PDF, o vão *dentro* de uma palavra (entre "U" e "I" de "EQUIVALENTES") chega a ser **maior** que um vão real entre duas palavras curtas, então nenhum limiar único separa os dois casos; (2) usar os caracteres de espaço literais do próprio PDF como sinal não funciona, porque a posição vertical deles às vezes arredonda para uma linha diferente da do texto da mesma linha visual; (3) baixar `_GAP_ESPACO` para `0.3` conserta alguns casos e quebra outros que hoje saem certos (`EQUIVALENTES` vira `EQU IVALENTES`). **Decisão explícita do usuário: não arriscar.** Valores monetários nunca são afetados, só a descrição de algumas contas, então o custo de regressão supera o benefício. A solução adotada é **avisar, não corrigir**: `ContabilApuracao.fonte_pdf_atipica` é `True` quando `_normaliza_titulo()` precisou de verdade (o título bateu sem acento mas não bateria com acento) para reconhecer a seção DRE ou Análise Vertical — sinal indireto mas real de que o PDF usa uma fonte diferente da de referência. `ResultadoExtracao.fonte_pdf_atipica` (`modelos.py`) carrega o valor; `create()`/`reprocessar()` persistem. Exposto em leitura nos dois serializers, sem rota de escrita. O frontend mostra um badge com tooltip ao lado do nome da empresa pedindo atenção redobrada aos nomes de conta; não bloqueia nem oculta nada. Validado contra os 3 PDFs reais disponíveis: `True` só para o `1751`, `False` para `792` e `2017` (sem falso positivo nos já confirmados corretos). ## Motor de regras de auditoria (`regras.py`) Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]`. **`historico` continua na assinatura de toda regra por uniformidade** (`gera_achados()` chama todas do mesmo jeito), mas **nenhuma das 9 regras atuais usa esse parâmetro**. `_contabil_monta_historico()` (em `views.py`, limitado às 2 apurações anteriores) e o parâmetro `busca_historico` de `pipeline.processa_apuracao()` continuam existindo de propósito, como ponto de extensão já desenhado — não foram arrancados só porque nada os usa hoje. ### As 9 regras (Mais um 10º tipo de apontamento que não é regra, ver "conta/linha removida no reprocessamento" abaixo.) 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. ### O 10º apontamento não é uma regra: conta/linha removida no reprocessamento `achados_itens_removidos(removidos)` (mesmo módulo, **fora** de `REGRAS`) monta um `AchadoDetectado` de severidade média por conta/linha que existia na apuração e não veio no arquivo do reprocessamento. Fica fora da lista porque `gera_achados()` só enxerga a extração do PDF atual, e "sumiu" só é visível comparando com o que estava salvo, que é o que a sincronização faz — quem chama é `reprocessar()` em `views.py`, juntando o resultado aos achados das regras antes de `_contabil_recria_achados()`. A construção do achado mora aqui mesmo assim, junto dos outros textos/severidades. O rótulo é `"codigo descricao"` no Balancete e o **caminho na árvore** na DRE/Análise Vertical (sem o ramo, o aviso não diria qual das linhas homônimas saiu). Rótulos iguais viram um apontamento só citando as duas tabelas, já que DRE e Análise Vertical são a mesma árvore e uma linha retirada do PDF some das duas. `conta`/`linha_analise_vertical` ficam nulos (o registro foi excluído), então o card não mostra "Ver na tabela" — e `codigo_conta` **não** é preenchido de propósito: `_contabil_recria_achados()` casa código contra as contas que sobraram, e um código repetido entre contas analíticas apontaria para a conta errada. > **O aviso vale para o reprocessamento em que a remoção aconteceu.** Achado é recriado do zero a cada reprocessamento e a linha já não está no banco, então reprocessar de novo com o mesmo arquivo não repete o aviso: naquele ponto não há mais nada sendo removido. É o mesmo espírito de `alterada_reprocessamento`, que também marca a mudança daquela rodada. **Por que não existe uma regra de variação sobre o Balancete**: a Análise Vertical do PDF só cobre linhas da DRE. A alternativa (comparar saldo de conta contra a apuração anterior, via `historico`) existiu e foi removida a pedido do usuário, junto de uma regra de razão Custos/Receita — a granularidade de `variacao_atipica_dre` sobre a Análise Vertical já cobre qualquer linha da DRE sem precisar de regra dedicada. ### Descendente de conta redutora não dispara `saldo_sinal_invertido` Se a conta "mãe" (sintética, em **qualquer** nível acima, não só o pai direto) começa com `"(-)"`, o sinal "invertido" dos analíticos dentro dela é o comportamento esperado daquela natureza de conta, não uma inconsistência. Caso real: duas analíticas dentro de `2.04.01.003` `"(-) CAPITALA INTEGRALIZAR"` (sic, espaço grudado — a mesma classe de artefato descrita em "PDF de fonte atípica") herdam o sinal devedor esperado, mas não têm `"(-)"` na própria descrição. `_indices_descendentes_de_conta_redutora(contas)` calcula, para a árvore inteira de uma vez, quais índices têm **algum** ancestral redutora, usando a pilha de níveis (`codigo.count(".")` + ordem de leitura do PDF) — **não** comparação de prefixo de código. O motivo é importante: o Questor reaproveita o mesmo código de classificação entre contas analíticas irmãs (ver também `ContabilObservacao.chave_conta()` abaixo), então string matching por código não é confiável para achar o pai; a pilha de níveis segue a profundidade real da árvore impressa, e funciona mesmo com códigos repetidos entre irmãos. Validado de duas formas: árvore sintética reproduzindo o caso do usuário (conta "(-) LUCROS DISTRIBUÍDOS" com 2 sócios dentro), confirmando que o achado deixa de ser gerado **e que seria gerado sem a correção**; e rodando contra as 3 apurações reais em produção, com 32 contas identificadas como descendente de redutora (a maioria depreciação acumulada, `1.02.05.007.*`), sem falso positivo aparente. ### Formatação de valores nas mensagens `_moeda(valor: Decimal) -> str` (local em `regras.py`, duplicado em vez de importado — este pacote é Python puro, sem tocar no ORM/app registry; mesmo padrão por arquivo de `indicadores/recibo.py`/`custo_contratacao/pdf.py`/`templatetags/contabil_extras.py`). Todas as mensagens de achado que interpolam um valor usam `_moeda()`, nunca `f"R$ {valor}"` direto (que usa `str(Decimal(...))` e nunca tem separador de milhar). **Pendência conhecida**: `regra_variacao_atipica_dre` ainda formata percentual com `f"{valor:.2f}%"` (ponto decimal, "12.34%"), inconsistente com o padrão BR. Mesmo tipo de ajuste, ainda não pedido. **Mensagem de achado é congelada no momento em que o achado é criado** — um achado já persistido mantém o texto com que nasceu até a apuração ser reprocessada (o que recria todo achado do zero) ou até uma apuração nova da mesma empresa ser criada. Não existe migração de dados reformatando texto já gravado: parsear números dentro de frase livre por regex é arriscado, já que o mesmo texto tem números que não são valores (código de conta "1.01.01.001"). ## Models (`portal_api/models.py`) Padrão cabeçalho → linhas de detalhe → apontamentos, mesma filosofia de `IndicadorApuracao`/`IndicadorApuracaoColaborador`. ### Apuração e linhas - **`ContabilApuracao`** — `codigo_empresa`/`nome_empresa`/`cnpj`/`competencia` (extraídos do PDF, não informados no upload) + `periodo_inicio`/`periodo_fim` + `arquivo` + `status` (`revisao`/`concluida`). `unique_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. E as três ordenam por `["ordem", "id"]`, não só por `ordem`: a árvore (o nível de cada linha em relação à anterior) e a chave natural da DRE/Análise Vertical dependem da ordem de leitura, então um empate de `ordem` não pode deixar o resultado à mercê do plano de execução do Postgres. ### Apontamentos de auditoria - **`ContabilAchado`** — nasce automático 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, caminho na árvore + nível na DRE/Análise Vertical (ver `chaves.py` e "Observações" abaixo). 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 fim: `.delete()`. > **O mapa de linhas já salvas é uma fila por chave (`_contabil_agrupa_por_chave()`), nunca um `{chave: linha}`.** Com dict, duas linhas de mesma chave viravam uma só: a perdedora ficava fora do mapa, então não era atualizada, e fora do laço de exclusão (que varre o mapa, não a tabela), então não era excluída — sobrevivia a todo reprocessamento com o valor congelado da primeira importação e sem nem o badge de `alterada_reprocessamento`. Foi assim que uma linha retirada do PDF continuou aparecendo na tela, com valor maior que o do próprio grupo pai. A chave por caminho resolve o caso comum (rótulo repetido em ramos diferentes), a fila cobre o resto (irmãs genuinamente idênticas no mesmo ramo): cada ocorrência do PDF novo consome uma da fila na ordem de leitura, e o que sobra é excluído, então a contagem de linhas salvas bate sempre com a do PDF. Os três devolvem `(origem, rótulo)` de tudo que excluíram, e `reprocessar()` transforma isso em apontamento na aba Observações (`regras.achados_itens_removidos()`, ver acima) — a exclusão em si é silenciosa, e uma conta sumir entre um arquivo e outro é exatamente o tipo de mudança que o contador precisa conferir. Pedido explícito do usuário. - **`_contabil_recria_achados()` — delete+recria total**, o 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, `"caminho na árvore|nivel"` na DRE/Análise Vertical (ex.: `"(-) DESPESAS OPERACIONAIS > DESPESAS DE VENDAS > DESPESAS COM PESSOAL|2"`). A regra mora em `chaves.py` (Python puro) e é consumida por `chave_conta()`/`chave_linha()` no model, `_contabil_chave_alvo()` na view e `dcChaveObsConta()`/`dcChaveObsLinha()` no JS — mesmo formato nos três. São exatamente as chaves que a sincronização do reprocessamento usa. > **O caminho na chave da DRE não é enfeite.** O mesmo rótulo aparece em ramos diferentes no mesmo nível ("DESPESAS COM PESSOAL" sob "DESPESAS DE VENDAS" e sob "DESPESAS ADMINISTRATIVAS"), e com a chave `(descricao, nivel)` a observação escrita numa vazava para a outra, além de o reprocessamento perder uma das duas linhas (ver "Reprocessar" acima). Mesma classe de problema que a descrição já resolvera no Balancete. > > Consequência prática: a chave de uma linha **não é calculável a partir da linha isolada**, só percorrendo a árvore. Por isso `_contabil_chave_alvo()` recebe a apuração inteira, `_contabil_arvore_contexto()` recebe uma lista de chaves alinhada por posição (não uma `chave_fn`), e o JS calcula as chaves por tabela em `dcAplicaChavesObs()` (no começo de `renderDre()`/`renderAnaliseVertical()`, não só ao abrir a apuração: marcar uma linha como validada troca o objeto pelo retorno da API). Ao mexer em qualquer um dos lados, os dois precisam continuar produzindo a mesma string. > **A descrição faz parte da chave do Balancete, e isso não é redundância.** O Questor reaproveita o mesmo código de classificação para várias contas analíticas de mesma natureza — confirmado pelo usuário com 6 bancos diferentes (Banco do Brasil, Inter, Itaú, Mercado Pago, PagSeguro, Sicredi) sob o mesmo código de "Depósitos Bancários à Vista". Com a chave só por `codigo`, uma observação escrita num banco aparecia em todos os outros. Trade-off aceito: uma conta **renomeada** com o mesmo código vira uma conta "nova" no reprocessamento (a antiga é excluída, outra é criada) — o mesmo trade-off que a DRE já aceitava. **Vigência** (`vigentes_para()`/`vigente_em()`): a observação aparece em toda apuração da mesma empresa com `competencia_origem <= C` e (`encerrada_em_competencia` nulo ou `>= C`). Daí saem os três caminhos: **manter** é não fazer nada; **encerrar** grava a competência aberta (a observação continua visível nela e some da seguinte em diante — o histórico nunca é reescrito); **incluir uma nova** cria outro registro, então uma conta tem uma thread, não um texto único. **Imutabilidade e exclusão**: - `texto` só é aceito enquanto a apuração de origem estiver "Em revisão" (`_garante_texto_editavel()`); depois disso a edição é recusada com 400, pedindo para registrar uma nova ou encerrar a existente. - Uma edição de texto que passa deixa rastro em `ContabilObservacaoEdicao`, exibida por um botão de relógio na thread — para editar não virar uma forma indireta de apagar uma observação importante reescrevendo por cima. - `DELETE` é permitido, mas com **duas** travas somadas: a mesma regra de "só em revisão" **e** `criado_por_id == request.user.id` (senão `PermissionDenied`). Mostrar ou esconder o botão no frontend é só UX; o servidor confere as duas de novo. - Excluir e encerrar convivem de propósito: excluir é definitivo e só de quem criou; encerrar é reversível e qualquer um do time pode usar. > Os ícones de "encerrar" e "excluir" **não podem ser o mesmo desenho**. Encerrar usa um ícone de arquivo (caixa com uma linha); a lixeira (`PID_DC_ICON_LIXEIRA`) é só da exclusão de verdade. Os dois aparecem lado a lado na mesma linha de ações, e uma é reversível e a outra não. ## Indicadores Todo indicador é um `IndicadorContabilDefinicao` no banco — **os 11 "de sistema" (ROA, ROE, Kanitz, EBIT, EBITDA, as três liquidezes, composição/grau de endividamento, IPL) também**. Não são caso especial em lugar nenhum do código: CRUD, cálculo e exibição passam pelo mesmo caminho de qualquer indicador personalizado. Qualquer contador com acesso à ferramenta pode criar/editar/excluir — não é tela administrativa restrita ao perfil "Inovação" (decisão deliberada: quem cria os indicadores é o próprio contador usando a ferramenta). ### Motor de fórmula (`formula.py`) `avalia_formula(expressao, valores)` faz `ast.parse(expressao, mode="eval")` e anda pela árvore com um **allowlist** restrito: só `BinOp` (`+ - * /`), `UnaryOp` (`+ -`), número literal e `Name` (resolvido contra o dict `valores`). Qualquer outro nó (chamada de função, atributo, import, comparação) levanta `FormulaInvalidaError` **antes de qualquer coisa ser executada** — nunca `eval()`/`compile()` sobre texto digitado pelo contador. Se qualquer nome referenciado valer `None`, ou a fórmula dividir por zero, o resultado inteiro é `None`. **"Indisponível" nunca vira 0** — disciplina seguida em todo o pacote. `valida_formula(expressao, chaves_disponiveis)` roda a mesma árvore com valores fictícios só para validar sintaxe/nomes na hora de salvar. ### Resolução contra uma apuração (`views.py`) - `_ContabilDadosIndicadores` (dataclass) + `_contabil_coleta_dados_indicadores(apuracao)` juntam `contas_atuais`/`dre_atual`/`resultado_liquido`/`historico_completo` e derivam `contas_anteriores` (só a apuração anterior imediata). Reaproveitado por todos os caminhos de cálculo, evitando duas idas ao banco pelos mesmos dados. - `_contabil_resolve_componente_personalizado()` resolve **um** componente: `contas`/`variacao_conta` somam em **valor absoluto** (Passivo/PL vêm negativos no relatório); `linha_dre` soma com o sinal já impresso (a DRE não segue essa convenção); `variacao_conta` é `soma_atual − soma_anterior`, `None` sem apuração anterior; `indicador` faz `valores.get(chave)`; `resultado_liquido` resolve sempre para `dados.resultado_liquido`. - `_contabil_calcula_indicadores_personalizados()` resolve **todas** as definições. **Iterativo, não ordenação topológica de verdade**: a cada rodada calcula todo indicador cujos componentes `tipo="indicador"` já têm valor, repetindo até não sobrar progresso. Cobre encadeamento sem ordenar dependências explicitamente. Um indicador cuja dependência nunca resolve (referência quebrada ou **ciclo** entre dois personalizados) fica `None` para sempre, **sem lançar erro** — uma fórmula mal configurada não pode derrubar o relatório inteiro. > **Por que existe o tipo `resultado_liquido`** em vez de um componente `linha_dre` apontando para a última linha: a última linha da DRE **muda de rótulo conforme o sinal do resultado** — uma empresa com prejuízo termina em "(=) PREJUÍZO LÍQUIDO DO EXERCÍCIO", com lucro terminaria em "(=) LUCRO LÍQUIDO DO EXERCÍCIO". Um `linha_dre` (que casa por texto exato) quebraria assim que o resultado de uma empresa virasse de sinal entre uma competência e outra. `resultado_liquido` resolve por **posição**, não por texto. ### Padrão vs. não padrão `padrao=True` (default) faz o indicador aparecer em toda apuração. `padrao=False` deixa o indicador salvo e editável, mas ele só aparece numa apuração em que sua chave esteja em `ContabilApuracao.indicadores_selecionados`. Um indicador não padrão não selecionado **não aparece em lugar nenhum** daquela apuração (nem relatório, nem aba "Dashboard"), mas continua na listagem completa do hub "Gerenciar Indicadores", que nunca é filtrada por apuração. `indicadores_ocultos` é ortogonal: esconde do **relatório do cliente** um indicador que está aparecendo. ### Exclusão de definição `perform_destroy()` bloqueia (400) se outra definição referencia esta pela fórmula, listando os nomes dependentes — para não deixar fórmula alheia quebrada em silêncio. Depois de excluir, **limpa a chave de toda `ContabilApuracao.indicadores_selecionados`/`indicadores_ocultos` que a referenciava**: sem essa limpeza a chave fica órfã e, como os dois serializers validam a lista **inteira** a cada alternância de checkbox, o usuário fica travado sem conseguir alternar nenhum indicador naquela apuração — não só o excluído. As duas validações também descartam chave inexistente em silêncio, como rede de segurança (é só estado de exibição, não dado auditado). ### Fórmula de cálculo vs. fórmula exibida `formula` é a expressão validada, usada só para calcular, e mostra as chaves internas dos componentes (`resultado_liquido - despesas_financeiras`). `formula_exibicao` é texto livre, nunca passa por `avalia_formula()`/`ast`, e é o que o cliente vê. No modal, os campos são rotulados "Fórmula (cálculo interno)" e "Fórmula (como aparece ao cliente)". O servidor resolve o fallback: `definicao.formula_exibicao.strip() or definicao.formula` — indicador sem a preferência preenchida mostra a fórmula técnica, nunca fica sem fórmula nenhuma. ### Ícones: duas cópias mantidas à mão O miolo `<svg>` de cada um dos 11 ícones existe em `_CONTABIL_ICONES_SVG` (`views.py`, montado em `card["icone_svg"]` via `mark_safe()`) **e** em `PID_DC_INDICADOR_ICONES` (`dashboard-contabil.js`, desenha a grade do seletor). Duplicação proposital: o relatório é HTML servido pelo Django (sem acesso ao JS do app) e o modal de cadastro é JS sobre uma `TemplateView` sem contexto de servidor — não há fonte única sem inventar mais uma ida ao backend. **Editar ou adicionar um ícone exige mexer nos dois lugares.** ### Riscos e calibragens herdadas - **Os códigos de classificação são fixos e calibrados contra um balancete de referência.** Ativo `"1"`, Ativo Circulante `"1.01"`, Estoques `"1.01.08"`, Imobilizado `"1.02.05"`, Depreciação Acumulada `"1.02.05.007"`, Passivo `"2"`, Passivo Circulante `"2.01"`, PL `"2.04"`. **Se um cliente usar numeração de plano de contas diferente, os indicadores dele saem errados silenciosamente.** O Passivo Não Circulante não tem código calibrado (não aparece no balancete de referência) e é calculado **por eliminação** (Passivo Total − Passivo Circulante − PL), sempre exato pela identidade contábil. - **Kanitz não foi validado** contra o BI antigo que esta ferramenta substitui. Usa a fórmula-livro-texto padrão (`0,05×ROE + 1,65×LiqGeral + 3,55×LiqSeca − 1,06×LiqCorrente − 0,33×GrauEndiv`), mas um valor visto numa captura do usuário ("11,31") está fora da faixa clássica (−7 a +7), sugerindo escala diferente no BI antigo. O relatório marca o card como estimativa. - **Liquidez Geral é aproximada**, sem exemplo real para validar: trata o Realizável a Longo Prazo como 0, porque o Ativo Não Circulante (`"1.02"`) mistura Investimentos/Imobilizado com um eventual RLP sem separar. - **EBIT é calculado "de baixo para cima"** (`resultado_liquido − despesas_receitas_financeiras`), não pela definição-livro-texto "de cima para baixo" (Receita Líquida − Custos − Despesas Operacionais). A `formula_exibicao` documenta o que o código faz, não a definição conceitual. Os dois caminhos tendem a convergir num DRE bem formado, mas não foram provados equivalentes. A linha de despesas/receitas financeiras é casada por substring (`LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS` em `parser.py`), **não validada** contra PDF real. - **EBITDA e Depreciação do mês ficam indisponíveis na primeira apuração de uma empresa** (dependem da variação do saldo de depreciação acumulada contra a apuração anterior). - **Grau de Endividamento e IPL não multiplicam por 100 na fórmula** — o texto original do glossário do usuário dizia "÷ (PL × 100)", que é só a forma de dizer "o resultado vira %". O cálculo é `A ÷ B`, exibido pelo filtro `percentual`. > **`dashboard_contabil/indicadores.py` está sem consumidor em produção e é mantido de propósito.** `calcula_indicadores()`/`IndicadoresFinanceiros`/`_saldo()`/`_divide()`/`CODIGO_*` continuam lá mesmo sem chamador (a wrapper `_contabil_calcula_indicadores()` em `views.py` existe e não é mais invocada). É a **única exceção neste projeto à convenção de apagar código sem uso**, justificada pelo risco de indicador financeiro: se um valor um dia parecer suspeito, dá para recalcular pelo caminho antigo e comparar. Antes da migração para o banco, um dry-run comparou os 11 indicadores calculados pelos dois caminhos contra a mesma apuração real e **bateram exatos até a vigésima casa decimal**, incluindo o `None` do EBITDA. Revisar se ainda vale manter depois de a migração provar estabilidade por um tempo. ## API Todos os endpoints usam `PermissaoApp("relatorios", "dashboard-contabil")`. Nenhum deles aparece na tabela de API do `CLAUDE.md` da raiz de propósito — endpoint de aplicação mora na doc da aplicação. | Endpoint | Método | Uso | |---|---|---| | `/api/contabil-apuracoes/` | GET/POST | histórico + criação (multipart, um PDF; empresa/competência vêm do arquivo) | | `/api/contabil-apuracoes/{id}/` | GET/DELETE | detalhe (contas + linhas DRE + Análise Vertical + achados de uma vez) / excluir. **DELETE é bloqueado (400) em apuração concluída** | | `/api/contabil-apuracoes/{id}/reprocessar/` | POST | multipart, PDF novo da mesma empresa+competência | | `/api/contabil-apuracoes/{id}/concluir/` | POST | fecha a análise (trava edições) | | `/api/contabil-apuracoes/{id}/observacoes/` | GET | recorte de vigência das `ContabilObservacao`, já com `historica`/`encerrada` calculados contra a competência desta apuração | | `/api/contabil-apuracoes/{id}/dashboard/` | GET | **o relatório HTML do cliente** (ver por que é GET, abaixo) | | `/api/contabil-apuracoes/{id}/indicadores/` | GET | `{indicadores, indicadores_ocultos, metadados}` para a aba "Dashboard" | | `/api/contabil-apuracoes/{id}/indicadores-ocultos/` | POST | substitui a lista inteira de chaves ocultas | | `/api/contabil-apuracoes/{id}/indicadores-selecionados/` | POST | idem, para indicador não padrão ativado nesta apuração | | `/api/contabil-apuracoes/{id}/resumo-fechamento/` | POST | texto rico do contador (sanitizado por `nh3`) | | `/api/contabil-apuracoes/{id}/pre-visualizar-indicador/` | POST | calcula um indicador **ainda não salvo** contra esta apuração, sem persistir nada | | `/api/contabil-apuracoes/{id}/exportar-xlsx/?parte=balancete\|dre` | GET | planilha da tabela | | `/api/contabil-apuracoes/{id}/resumo-pdf/` | GET | PDF avulso da aba Resumo | | `/api/contabil-contas/{id}/` | GET/PATCH | `validado` (e demais campos graváveis) de uma conta | | `/api/contabil-linhas-dre/{id}/` | GET/PATCH | idem, linha da DRE | | `/api/contabil-linhas-analise-vertical/{id}/` | GET/PATCH | idem, linha da Análise Vertical | | `/api/contabil-observacoes/` | POST | cria (recebe `apuracao` + `alvo_tipo` + `alvo_id`; empresa, competência, chave e rótulo são derivados no servidor — alvo de outra apuração é 400) | | `/api/contabil-observacoes/{id}/` | PATCH/DELETE | `texto` e/ou `mostrar_ao_cliente`, cada um com sua regra / exclusão restrita ao autor e à revisão | | `/api/contabil-observacoes/{id}/encerrar/`, `/reativar/` | POST | recebem a apuração aberta no corpo, que define a competência de corte | | `/api/contabil-achados/{id}/` | GET/PATCH | tratar/ignorar (exige `observacao_contador` não vazia) | | `/api/contabil-achados/{id}/alternar-oculto/` | POST | esconder a observação do achado no relatório, **independente da tratativa** (um achado pode continuar pendente e ter a observação escondida) | | `/api/contabil-indicadores-definicoes/`, `/{id}/` | GET/POST/PATCH/DELETE | CRUD de indicador (corpo sempre o indicador **inteiro**, inclusive em PATCH) | Notas de desenho: - **`ContabilApuracaoViewSet` não tem PATCH genérico** (`http_method_names` exclui `"patch"` de propósito). Toda edição de campo da apuração passa por uma `@action` dedicada que substitui aquele campo de uma vez, sempre guardada por `_contabil_garante_em_revisao()`. - **`ContabilObservacaoViewSet` não tem `list`/`retrieve`** de propósito: a leitura é sempre pelo recorte de vigência de uma apuração. - **`indicadores()` devolve `dataclasses.asdict(...)` puro**, sem passar por `Serializer` — os `Decimal`/`None` já chegam certos no JSON porque o `JSONRenderer` do DRF aplica seu encoder recursivamente em qualquer estrutura de resposta, não só em campo de `Serializer`. - `get_queryset()` faz `prefetch_related` de `achados__conta`, `achados__linha_analise_vertical`, `reprocessamentos__reprocessado_por` (este só em `list`) e `edicoes__editado_por` nas observações. **`total_achados_pendentes` ainda gera N+1 na listagem** — conhecido, não corrigido. ## Frontend — tela de revisão (`dashboard-contabil.html` / `.js` / `.css`) Três sub-views no padrão de `indicador-desempenho.html`: `#dc-list-view` (histórico + "Nova Análise") / `#dc-form-view` (upload de um PDF, sem campo de competência) / `#dc-review-view` (abas via `.pa-tabs`/`.pa-tab-panel` de `perfis-acesso.css`). Abas da revisão: **Observações** (os achados) / **Balancete** / **D.R.E.** / **Análise Vertical** (só quando `analise_vertical_meses` não está vazio) / **Dashboard**. > Se a apuração aberta não tem Análise Vertical mas essa aba estava ativa (o contador vinha de outra apuração), `renderRevisao()` força a volta para "Observações" — senão sobraria um painel visível com o botão de aba escondido. ### As três árvores Balancete, D.R.E. e Análise Vertical usam **o mesmo algoritmo de árvore recolhível**. O Balancete deriva o nível do código de classificação (`dcContaNivel()`, conta segmentos separados por `.`); as outras duas já recebem `nivel` pronto do backend. "Tem filhos" é sempre "a próxima linha tem nível maior". As funções genéricas (`dcColapsoPadrao`/`dcUltimaLevaVisivel`/`dcFilhosDiretos`/`dcDescendentes`/`dcEstadoValidacaoGrupo`/`dcValidadoInfo`) recebem `itens`/`nivelFn` como parâmetro e servem as três sem alteração. Estado de colapso é independente por aba (`dcContasColapsadas`/`dcDreColapsadas`/`dcAvColapsadas`). O cabeçalho da Análise Vertical é montado em JS (`renderAnaliseVerticalHead()`) porque o número de colunas varia com os meses; nas outras duas é HTML fixo. Os listeners são delegados no `<thead>`, então sobrevivem ao `innerHTML` ser refeito. **As tabelas nascem com tudo expandido.** `renderRevisao()` reinicia os três `Set()` de colapso vazios. A visão compacta virou ação sob demanda (ver o botão abaixo). **`dcColapsoPadrao(itens, nivelFn)`** é a visão compacta: popula o `Set` com todo item que tem filhos **e** está no nível `PID_DC_NIVEL_ABERTO_PADRAO` (`2`) ou além. Não é padrão de abertura aqui, mas **continua sendo o padrão do relatório do cliente** (que é server-side). `PID_DC_NIVEL_ABERTO_PADRAO` (JS) e `_CONTABIL_NIVEL_ABERTO_PADRAO` (`views.py`) são a mesma constante conceitual duplicada nos dois lados — mudar o limiar exige ajustar os dois. ### Cabeçalho: dois botões por tabela, em pontas opostas - **`.dc-recolher-grupos-btn`** (`data-dc-recolher-grupos="balancete|dre|analise-vertical"`), na primeira coluna, dentro de `.dc-th-linha.dc-th-linha--inicio` (modificador que só troca o `justify-content`, já que este botão vem **antes** do rótulo). É um **toggle de duas faces**: mostra "−" e aplica `dcColapsoPadrao()` enquanto nada está recolhido; mostra "+" e zera o `Set` assim que existe qualquer grupo recolhido. A face sai de `dcAtualizaBotaoArvore()`, chamada no fim de cada `render*()`, e é **derivada de `colapsadas.size`**, não de um flag próprio — então continua correta quando o contador recolhe uma linha pelo toggle dela, sem passar pelo cabeçalho. - **`.dc-reset-formatacao-btn`** ("Restaurar formatação"), colado à direita na coluna "Observação". Faz o mesmo que a face "+". Ficou redundante e foi mantido de propósito, por ser a "borracha" que o usuário já conhece de outras telas do Portal. **Ponto não-óbvio: os dois zeram `dc*Expandidos` em vez de populá-lo com todos os grupos.** Com o conjunto vazio, `dcDestaqueGrupos()` cai na leva padrão, que sem nada recolhido marca exatamente as **folhas**. Popular `dc*Expandidos` com todos os grupos destacaria quase toda linha da tabela, e com tudo destacado nada se destaca. ### Destaque de linha Duas famílias de `Set()` por árvore (as 6 resetadas em `renderRevisao()` e no botão de restaurar): `dc*Expandidos` guarda os grupos que o contador abriu e continuam abertos; `dc*Destaque` guarda o resultado **derivado**, sempre recalculado por `dcDestaqueGrupos(itens, nivelFn, colapsadas, expandidos)`: - `expandidos` vazio → vale a leva padrão, `dcUltimaLevaVisivel(itens, nivelFn, colapsadas)`. - `expandidos` com algo → o destaque é **só** a união de `dcFilhosDiretos()` de cada grupo aberto, **descartando a leva padrão por completo**. O handler de clique guarda `estavaColapsada` **antes** de mutar o `Set` de colapso; expandir adiciona o id a `expandidos`, recolher remove o id **e todo descendente dele** (senão o destaque apontaria para linhas agora invisíveis e a árvore nunca voltaria ao estado padrão). Puramente visual, sem persistência. > **Por que a leva padrão é descartada quando há grupo aberto, e não somada.** Somar os filhos revelados ao conjunto já existente parece o comportamento natural e **não funciona**: a leva padrão já marca praticamente toda linha de nível ≥ 2, então somar deixa a tabela inteira destacada. Confirmado simulando o algoritmo em Python contra contas reais. A regra correta é acumular **entre grupos abertos** (abrir um segundo grupo mantém o destaque do primeiro) mas ignorar a base padrão enquanto houver qualquer grupo aberto. **`dcUltimaLevaVisivel()` marca folha genuína, não só grupo colapsado.** Reconstrói a lista de linhas realmente visíveis dado o colapso e marca toda linha cuja **próxima linha visível não seja mais profunda que ela**. Isso cobre os dois casos com uma regra só: nada foi revelado abaixo dela, seja porque está colapsada ou porque é uma folha sem filho nenhum. Um critério baseado só no `Set` de colapso deixa de fora folhas de verdade (como `(-) SIMPLES NACIONAL` na DRE), que visualmente estão no mesmo nível de um grupo colapsado vizinho. **Cores** (`dashboard-contabil.css`): `--dc-destaque-bg` é o tom **mais escuro** (`--bg-canvas`) e `--dc-row-tint-bg` o mais claro (`--bg-surface-raised`) no tema escuro. `--dc-row-tint-bg` não pode ser `--card-bg-hover`, senão o hover das linhas não destacadas fica sem efeito visível. O tema claro usa a inversão oposta (não-destaque colorido, destaque em branco), também por pedido do usuário. ### Checkbox "validado" com tri-state Botão de check ao lado do de observação (`.dc-conta-validado-btn`, cor `--teal` quando marcado — deliberadamente não `--accent`, que já significa "observação preenchida" e é a cor de tema escolhida pelo usuário). Puramente informativo: não bloqueia conclusão, não afeta achado nem relatório. Uma **folha** é um toggle simples. Uma **sintética** tem 3 estados, calculados a cada render a partir dos descendentes (todos, não só os diretos) via `dcEstadoValidacaoGrupo()`: - `"nenhum"` — nem ela nem nenhum descendente está validado. - `"parcial"` (`--gold`) — qualquer combinação intermediária, **inclusive** só a própria sintética marcada. - `"completo"` (`--teal`) — **todo descendente FOLHA** está validado, checado primeiro. > **O "completo" usa só `descendentesFolhas`, não `descendentes` completo.** Numa árvore de 3+ níveis (mãe → filha → netos), validar os netos direto sem clicar na "filha" intermediária nunca marca o campo `validado` dela no banco — só o estado visual dela é "completo", calculado por render. Checar todo `descendentes` na "mãe" fazia o grupo nunca fechar mesmo com todo neto validado. `descendentes` (todos) continua valendo para o "parcial": uma sintética marcada sozinha ainda deve sinalizar "em andamento" num ancestral. Ciclo de 3 cliques numa sintética (`dcClicarValidadoConta()`/`Linha()`/`Av()`), recalculando o estado a cada clique: 1. `"nenhum"` → PATCH só na própria sintética → vira `"parcial"`. 2. `"parcial"` → `pidConfirm("Deseja validar todas as contas deste grupo?")` → PATCH em lote de todo descendente ainda não validado → `"completo"`. Cancelado, nada muda. 3. `"completo"` → PATCH em lote desmarcando tudo, **sem perguntar**. Cada PATCH é individual (não existe action de lote); o "lote" é `Promise.all` client-side, aceitável porque um grupo real tem no máximo algumas dezenas de contas. Todo caminho termina re-renderizando a tabela inteira — necessário porque o estado de uma sintética **ancestral** também pode ter mudado de cor. Efeito colateral aceito: um editor de observação aberto em outra linha perde texto ainda não salvo nesse recálculo. ### Observações: thread inline por linha As observações são carregadas **à parte** da apuração (`dcCarregarObservacoes()`, ao abrir/criar uma análise) e indexadas por chave natural (`dcObsIndice`), porque não pertencem ao payload da apuração. Clicar no ícone de observação abre uma `<tr class="dc-obs-edit-row">` extra logo abaixo da própria linha, dentro da mesma tabela — **não um modal**. Motivo: um clique acidental fora de um popup não deve descartar texto em digitação, e ver a conta ao lado da observação ajuda a não perder o contexto. O painel é uma thread (`dcObsPainelHtml()`/`dcObsItemHtml()`): histórico em cima (autor, data, competência de origem, selos "Histórico"/"Encerrada"/"Editada"/"Aparece ao cliente"/"Interna" e as ações de olho, ver edições, editar, encerrar/reativar, excluir), campo de observação nova embaixo. Um handler único (`dcTrataCliqueObservacao()`) atende as 3 tabelas e as 4 listas de resumo — **a mesma observação pode estar visível em mais de um lugar ao mesmo tempo**, então cada mutação refaz o fetch e re-renderiza tudo (`dcRenderObservacoesTudo()`). **O botão da coluna mostra sempre só o ícone, nunca o texto** (`PID_DC_OBSERVACAO_ICONE`, botão circular de 26px), com um contador (`.dc-obs-contador`, já que uma conta pode ter várias). A cor é o sinal: - `.dc-conta-observacao-btn--preenchida` (`--accent`) — esta linha tem observação própria. - `.dc-conta-observacao-btn--descendente` (`--gold`) — a linha é sintética, **não** tem observação própria, mas algum descendente tem. Serve para o contador ver que há observação dentro de um grupo recolhido sem precisar expandir. Clicar continua abrindo o painel da própria linha (que nasce vazio), é só sinal visual. Calculado por `dcTemObservacaoDescendente()`. Os chips "Todas / Visíveis ao cliente / Internas" (`dcObsFiltro`) existem nas quatro listas e **compartilham a mesma variável**: filtrar numa aba filtra em todas. Cada aba termina com um resumo próprio (`.dc-obs-resumo`, via `renderContasObsResumo()` e irmãs, chamadas no fim de cada `render*()`, sempre em sincronia com a tabela) e a aba "Dashboard" tem a lista consolidada das três + auditoria. As duas coisas convivem de propósito: uma é a visão de uma aba, a outra é a visão consolidada antes de gerar o relatório. ### Aba "Observações" (achados) Filtros por severidade, por status e por regra, combinados por E lógico. Como não há chip próprio para o filtro por regra, uma faixa (`#dc-regra-filtro-ativo`) aparece entre os chips e a lista, mostrando a regra ativa e um botão "Limpar" — sem ela não haveria como perceber por que a lista filtrou nem como sair. Ordenação sempre por severidade (`PID_DC_SEVERIDADE_ORDEM = {alta:0, media:1, baixa:2}`), preservando a ordem original dentro da mesma severidade (`Array.prototype.sort` é estável). **Resumo no topo** (`.dc-achados-resumo`): um donut em SVG puro com a contagem por severidade e o total no centro, mais uma grade de 3 cards por **grupo temático** (`PID_DC_GRUPOS`): "Divergências de Saldo" (regras 1-5), "Contas Atípicas" (6-8) e "Variações e Indicadores" (9). Cada card mostra o total do grupo e, por baixo, uma linha por regra (label + contagem); regra sem achado continua listada com `0`, só não clicável. `renderAchadosResumo()` conta sempre sobre `apuracaoAtual.achados` **completo**, nunca sobre a lista filtrada — é uma visão geral estável. > Sem Chart.js aqui de propósito (essa dependência só existe no relatório estático). O donut usa a técnica clássica de `<circle r="15.9155">` — circunferência ≈ 100, então `stroke-dasharray`/`stroke-dashoffset` já funcionam em unidades de percentual sem `pathLength`. Cada segmento é um `<circle>` próprio, clicável individualmente porque `pointer-events` de SVG só considera pixel realmente pintado pelo `stroke`, sem hit-test manual por ângulo. Donut, legenda e linhas de regra são clicáveis e filtram a lista abaixo, com `scrollIntoView` suave até ela (o resumo pode empurrar a lista para fora da tela). O cabeçalho do card de grupo **não** é clicável, só as linhas de regra dentro dele. **Cada card de achado** pode expandir a conta citada (`.dc-achado-card__toggle-conta`), resolvida procurando em `apuracaoAtual.contas` — sem chamada de API extra. As duas regras gerais não têm conta, então não mostram o toggle. **Botão "Ver na tabela"** (`PID_DC_ICON_LOCALIZAR`), condicionado a `achado.conta != null || achado.linha_analise_vertical != null`. `dcIrParaLinha(tipo, id)`: 1. Acha o índice da linha no array certo (via `DC_IR_PARA_CONFIG`). 2. Calcula só os **ancestrais** dela (`dcAncestraisIds()`) e tira só esses ids do `Set` de colapso — **não** a árvore inteira, preservando o resto do estado de expansão que o contador já montou. 3. Marca `dcFocoLinha` e re-renderiza, aplicando `.dc-conta-row--foco` só na linha alvo. 4. Clica programaticamente no botão da aba certa, reaproveitando o listener de troca de aba. 5. Num `requestAnimationFrame` (depois do painel visível), `scrollIntoView({block:"center"})`. 6. Um `setTimeout` de 2,4s limpa o foco e re-renderiza — o pulso nunca fica grudado. `.dc-conta-row--foco` anima `box-shadow`, **não `background-color`** (que já está em disputa entre o tingimento padrão e `.dc-conta-row--destaque`), então o pulso aparece por cima de qualquer estado que a linha já tenha. > **Não existe "ir para a DRE"** porque nenhuma regra hoje referencia `ContabilLinhaDre` diretamente. Se uma regra nova precisar, o padrão se replica sem reprojetar nada: FK `linha_dre` + `ordem_linha_dre` em `AchadoDetectado` + uma entrada em `DC_IR_PARA_CONFIG` com `tab: "dre"`. ### Aba "Dashboard" Mostra, **antes de gerar o relatório**, os mesmos cards de indicador e a mesma lista de observações que vão para o relatório, com um botão de olho em cada item para escondê-lo do relatório final sem apagar o dado. `dcIndicadoresAtual` é buscado sob demanda só na primeira vez que a aba é aberta (e resetado para `null` em `renderRevisao()` e após qualquer criação/edição/exclusão de indicador) — evita um cálculo e uma consulta ao histórico completo toda vez que uma apuração é aberta, já que boa parte das revisões não chega a abrir essa aba. A lista de observações **não** tem fetch próprio, é derivada do payload já carregado. `renderDashboardIndicadores()` agrupa as chaves pelo campo `.grupo` que vem do servidor; só a **ordem** dos grupos é fixa no frontend (`PID_DC_DASH_ORDEM_GRUPOS`, grupo desconhecido cai no fim) — é o que faz "Indicadores Personalizados" aparecer sem hardcodar nada a cada indicador criado. Card clicável abre o modal "Ver fórmula" (funciona igual para indicador de sistema ou personalizado, já que os dois têm a mesma forma de metadado). Card de indicador personalizado ganha um botão de lápis a mais. **Botões de olho ficam `disabled` quando a apuração está concluída**, mesmo espírito do resto. `pidDcFormatIndicadorMoeda`/`Percentual`/`Indice` espelham os filtros do template em JS e **sempre transformam `null`/`undefined` em "—", nunca "R$ 0,00"**. Não reaproveitam `pidDcFormatMoeda()`, que trata `null` como `0` de propósito (usado só em valores de conta/DRE, que nunca são `None`). **Resumo do Fechamento**: editor `contenteditable` com colar/arrastar imagem (mesmo padrão de `.ag-richtext`/`.ajuda-modal__editor`, duplicado aqui de propósito — nenhum dos três é componente compartilhado; limite de 2MB por imagem). Populado **só em `renderRevisao()`**, uma vez por apuração aberta, **nunca em `renderDashboardTab()`** — resetar o `innerHTML` a cada troca de aba descartaria texto ainda não salvo. Sem toggle editar/visualizar: é sempre editável enquanto a apuração está em revisão, porque só o próprio contador vê esta tela (ao contrário do texto de "Mais informações", lido por todos e editado só pelo perfil "Inovação"). ### Modal "Novo/Editar Indicador" `.modal-card--wide`, com nome/descrição/formato/ícone/fórmula técnica/fórmula de exibição e uma lista dinâmica de componentes. Cada linha tem chave + tipo e um "picker" que muda conforme o tipo: `contas`/`variacao_conta` e `linha_dre` reaproveitam `.checklist-box`/`.checklist-item`/`.checklist-search` de `components.css` (necessário: uma apuração real tem 100-500 contas, sem busca a lista seria inutilizável); `indicador` vira um `<select>`; `resultado_liquido` mostra só um texto explicativo. O picker é reconstruído só quando o tipo muda, não a cada tecla da busca (que só alterna `hidden` nos itens já renderizados). **Scroll**: `.modal-card` (global, `components.css`) tem `max-height: calc(100vh - var(--space-5)*2)` + `overflow-y:auto`. A lista de componentes **não** tem scroll próprio — ter os dois aninhados por cima do `.checklist-box` (que já rola) deixava a área útil menor que um componente inteiro. Sobra só um scroll aninhado, o do checklist, que é genuinamente necessário. **Componente "órfão" ao editar de outra apuração**: os pickers são montados a partir da apuração aberta agora, mas um componente pode ter sido configurado a partir de outra empresa/competência. Todo código/descrição salvo que não existe na apuração atual entra como item extra no topo do checklist, **já marcado** e com a nota "não encontrada nesta apuração, mantida" — senão salvar sem tocar naquele componente apagaria a referência em silêncio. **"Calcular com esta apuração"** chama `pre-visualizar-indicador/` com o formulário **ainda não salvo** e mostra o valor de cada componente mais o resultado final, sem persistir nada. Cada componente é formatado com `indice` (número BR simples, sem R$/%) porque um componente pode ser qualquer grandeza; o resultado usa o `formato` escolhido. O painel é escondido sempre que o modal abre, para nunca mostrar um resultado desatualizado. **Fechar pelo overlay ou "Cancelar" pede confirmação** (`pidConfirm`, `{perigoso: true}`). Salvar/excluir fecham direto — não há o que descartar depois de uma ação concluída. ### Lista/histórico (`#dc-list-table`) Ordenação e filtro por coluna, client-side, mesmo mecanismo de `#ips-list-table` (Importação de Plano de Saúde), portado e renomeado com prefixo `dc-` — **não compartilhado** entre os dois arquivos JS/CSS. `carregarLista()` só busca a API e guarda em `dcListaApuracoes`; `renderList()` (sem fetch) filtra, ordena e desenha — chamada por qualquer mudança de ordenação/filtro, sem round-trip. - **Ordenação padrão**: `criado_em` decrescente (a última execução primeiro), decisão explícita do usuário, diferente do `Meta.ordering` do model (que prioriza competência). - **Colunas ordenáveis**: empresa (por `codigo_empresa`), competência, observações (`total_achados_pendentes`), status, criado_por, criado_em. - **Colunas com filtro estilo Excel** (funil, popup com busca + checklist): empresa, competência, status, criado_por. Ficam de fora `observacoes` (contagem, não dimensão de agrupamento) e `criado_em` (granularidade fina demais). - **`valoresDistintos()` ordena pelo valor bruto, não pelo rótulo formatado.** Importa para competência: ordenar por "MM/AAAA" agruparia por mês antes do ano ("01/2026" antes de "12/2025"), enquanto a string ISO já ordena cronologicamente por comparação simples. O popup mostra o rótulo legível, mas indexa pelo valor bruto. - Botão "borracha" (`#dc-list-reset-btn`) limpa os quatro filtros e volta a ordenação ao padrão. - **Sem paginação** (diferente de Importação de Plano de Saúde) — o histórico tende a ser curto, uma linha por empresa+competência. Na linha concluída, o ícone de lixeira **some** (mesmo padrão do botão de reprocessar ao lado; misturar "some" e "aparece desabilitado" na mesma linha seria incoerente). O handler ainda tem `try/catch` + `pidAlert`, porque a trava do servidor continua valendo para uma lista carregada antes de outra pessoa concluir a análise. ### Tooltip no visual do Portal `pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto)` monta um par gatilho+texto (`.dc-hover-tooltip`), nunca `title="..."` (o balão nativo do Chrome quebra a identidade visual, mesmo raciocínio de `pidConfirm`/`pidAlert` no lugar de `window.confirm`/`alert`). Diferente de `.info-tooltip` de `components.css` por usar `white-space: pre-line` (preserva quebras deliberadas e envolve o resto) em vez de `nowrap`. > **`position: fixed`, não `absolute`.** Os badges deste pacote vivem dentro de `.pa-table-wrap`, que tem `overflow:hidden` para arredondar o canto da tabela — qualquer coisa `absolute` que escape da tabela é cortada. Com `fixed` + `top`/`left` calculados em JS (`pidDcPosicionaTooltip()`), o balão é relativo à viewport: centralizado acima por padrão, desce quando não há espaço acima, clampado nas laterais. Delegado no `document` com `capture:true` (`mouseenter`/`focus` não borbulham), então um único par de listeners cobre todo badge atual e futuro, mesmo os recriados a cada re-render. `tabindex="0"` no gatilho + `:focus-visible` mostram o tooltip por teclado também. Não promovido para `components.css` por enquanto — só usado aqui, mas a mecânica é genérica se outro pacote precisar. ## Relatório do cliente (`dashboard-contabil-relatorio.html` + `views.py`) `ContabilApuracaoViewSet.dashboard()` gera um documento HTML **autocontido** — não estende o shell do Portal e **nunca usa a marca "P.I.D."**: carrega `logo-branco.png`, a identidade do escritório, porque é um documento emitido ao cliente. Ver `[[feedback_logos_documentos_vs_portal]]` na memória. > **É GET, não POST**, diferente do padrão `/gerar/` das outras ferramentas. A primeira versão era POST e o frontend abria via `fetch` + `URL.createObjectURL(blob)` + `window.open()`. Um documento carregado de uma URL `blob:` tem origem sintética, e URLs relativas geradas por `{% static %}` não resolvem de forma confiável nesse contexto — a logo do escritório simplesmente não carregava. Com GET o frontend abre a URL da API direto (`pidGerarDashboardContabil()` é só `window.open(...)`), navegação de verdade, mesma origem, sem blob nem CSRF. **Abas** (`.dcr-tabs`, JS puro inline — não reaproveita `.pa-tabs`, que não é carregado neste documento): Balancete (inicial) → D.R.E. → Análise Vertical (só com `{% if analise_vertical_meses %}`) → Resumo. O `data-dcr-tab` do Resumo continua `"indicadores"` internamente, nome anterior ao da aba ganhar mais conteúdo. Na impressão (`@media print`) a barra de abas some e todas ficam visíveis, cada uma numa página (`page-break-after`); linhas recolhidas são forçadas a aparecer (`tr[hidden] { display: table-row !important }`); botões de imprimir/exportar/restaurar/voltar-ao-topo somem. **Documento impresso não esconde conteúdo atrás de aba não clicada nem de grupo recolhido.** ### Estrutura de cada aba Cada aba de tabela tem a seção **"Observações do X" ACIMA da tabela**, depois a tabela. A aba Resumo tem o Resumo do Fechamento, os grupos de cards de indicador e "Todas as Observações da Análise" (as três listas + auditoria juntas, cada item com prefixo de origem: "Balancete — ", "D.R.E. — ", "Análise Vertical — ", "Auditoria — "). **Clicar numa observação rola até a conta/linha que ela referencia, se houver.** `dashboard()` calcula uma `ancora` por observação (setada no objeto Python, não é campo do model), igual ao `data-dcr-id` da linha correspondente, casando pela mesma chave natural. Fica `None` quando a conta não existe mais nesta apuração (observação histórica de conta que saiu do plano) — o "se houver": a observação continua aparecendo, só não vira link. Só o `<li>` com âncora ganha `data-dcr-obs-ir` + `role="button"` + `.dcr-obs-item--clicavel`. ### Árvore recolhível server-side Diferente da tela de revisão (SPA que re-renderiza a tabela a cada clique), aqui o HTML é gerado uma vez: `nivel`, `tem_filhos` e `colapsado_padrao` são calculados **no servidor** (`_contabil_arvore_contexto()`, mesmo algoritmo) e viram `data-dcr-nivel`/`data-dcr-tem-filhos`/`data-dcr-id`/`data-dcr-colapsado-padrao` em cada `<tr>`. `pidDcrArvore(tbodyId)` só alterna o atributo `hidden` das `<tr>` existentes, sem reconstruir HTML. **O relatório nasce recolhido** (`colapsado_padrao = tem_filhos and nivel >= _CONTABIL_NIVEL_ABERTO_PADRAO`), ao contrário da tela de revisão. É a única tela onde `dcColapsoPadrao` ainda é padrão de abertura, e é assim de propósito: é o documento que vai ao cliente, tem só o botão de restaurar, e mudar o que o cliente vê não foi pedido. `pidDcrArvore()` devolve `{ expandeAte, resetar }`; `pidDcrObs()` devolve `{ fecharTudo }`. `expandeAte(id)` sobe a cadeia de ancestrais e reabre **só** os colapsados no caminho, não a árvore inteira. `resetar()` devolve tudo ao estado inicial do servidor. Dois dicionários (`dcrArvores`/`dcrObsControles`) casam o `tbodyId` com a árvore/painel certos, então cada botão "Limpar formatação" só afeta a própria tabela. O destaque de linha é replicado aqui com a mesma lógica da tela de revisão (`calculaDestaque()`/`expandidos`/`descendentes()` espelhando as funções do JS do app). **Os dois lados são mantidos em sincronia manualmente** — mudança na lógica de destaque precisa ser aplicada nos dois arquivos. **Observação inline no relatório**: coluna "Observação" com ícone que só aparece quando há observação vigente **e** `mostrar_ao_cliente` — observação interna não vaza nem como ícone. Clicar abre uma `<tr class="dcr-obs-inline-row">` já presente no HTML (nasce `hidden`). A visibilidade é sincronizada a **todo** clique no corpo da tabela (inclusive os de expandir/recolher) a partir de dois fatores: se o usuário abriu aquele painel **e** se a linha-pai está visível — assim, colapsar um grupo ancestral fecha os painéis abertos dentro dele sem duplicar a lógica de pilha. **Essas linhas de observação são excluídas da lista que `pidDcrArvore()` percorre** (`filter` por `data-dcr-obs-row`): incluí-las quebraria a pilha de colapso, já que não têm `data-dcr-nivel` próprio. ### Análise Vertical: tabela larga Essa tabela pode ter bem mais colunas (Descrição + 2 por mês + Observação = 8 para 3 meses) e `.dcr-page` tem `max-width:1140px`. Duas mudanças resolvem: - `.dcr-tabela-wrap { overflow-x: auto }` (era `overflow:hidden`, que zerava os dois eixos; `overflow-y` continua `hidden`) e `table.dcr-tabela th { white-space: nowrap }` — sem isso o navegador espremia as colunas até quebrar o cabeçalho em 3 linhas e cortar a última coluna para fora da área visível. - `.dcr-tabela--compacta` (só no `<table>` da Análise Vertical): fontes e paddings menores, ícones menores, e o filtro **`mes_curto`** no cabeçalho (`"mai/2026"` → `"mai/26"`), já que "— Valor"/"— Variação" repetido por mês era o maior consumidor de largura. Objetivo: o caso comum (3 meses) caber sem rolar. O `overflow-x` continua como rede de segurança para mais meses. ### Coluna "Observação" fixa na borda direita (as três tabelas) Balancete e D.R.E. não são compactadas como a Análise Vertical (ver acima) porque a expectativa era que sempre coubessem em `.dcr-page` sem precisar rolar — na prática, uma empresa com valores grandes (muitos dígitos em Saldo Anterior/Débito/Crédito/Saldo Atual) ou plano de contas mais fundo (indentação de `nivel_px` na Descrição) pode ultrapassar a largura disponível, e nesse caso o `overflow-x:auto` do wrap entra em ação. Sem nenhum tratamento a mais, isso empurrava o botão "Restaurar formatação padrão" e o ícone de observação por linha (última coluna) pra fora da área visível — só alcançáveis arrastando a tabela pro lado, achado real do usuário contra um balancete de empresa grande. A última `<th>`/`<td>` de cada uma das três tabelas (Balancete, D.R.E., Análise Vertical) ganhou a classe `dcr-col-observacao`, com `position: sticky; right: 0`. A coluna passa a ficar sempre colada na borda direita de `.dcr-tabela-wrap`, visível independente de quanto as outras colunas precisem rolar — sem precisar limitar largura/fonte das demais colunas (o que arriscaria regressão no caso comum, que já cabe). O fundo já vem de propriedades existentes (`background` do `th` genérico, `--dcr-row-bg` por linha no `td`), então não precisou de override de cor: como o valor de `--dcr-row-bg` é o mesmo em toda célula da mesma linha, a composição da coluna fixa sobre as células que passam por baixo dela ao rolar é visualmente idêntica a uma cor sólida. **Não se aplica** à `<tr class="dcr-obs-inline-row">` (o `<td colspan>` da observação expandida) — só a linha "normal" da conta/linha tem a classe. ### Filtros de template (`portal_api/templatetags/contabil_extras.py`) Primeiro (e único) uso de template tags customizadas no projeto. `moeda`/`percentual`/`indice`/`competencia` no padrão brasileiro; `None` sempre vira "—", nunca "R$ 0,00"/"0,00%". `numero_bruto` devolve o valor cru só para o atributo `data-count` da animação, nunca para texto exibido. `mes_curto` para o cabeçalho da Análise Vertical. **`moeda_av`/`percentual_av` são separados de propósito**: `ContabilLinhaAnaliseVertical.valores` grava valor/percentual como **texto**, então `moeda` (que faria `f"{valor:,.2f}"`) falharia contra uma string, e `percentual` multiplicaria por 100 um número que já é um percentual pronto. Mesmo cuidado no JS: `pidDcFormatPercentualAnaliseVertical()` é deliberadamente diferente de `pidDcFormatIndicadorPercentual()` — usar o errado exibiria `10000,00%`. Um indicador personalizado chega ao contexto **já formatado como texto** (`_contabil_formata_indicador()`), porque o Django Template Language não permite escolher um filtro por nome vindo de variável. ### Visual e animações Fontes "Manrope" (títulos/cards/abas), "Inter" (corpo, `font-variant-numeric: tabular-nums` nas colunas de valor) e "JetBrains Mono" (fórmula do verso do card). Paleta roxo/dourado dos documentos do escritório (`#3d2178`/`#281552`/`#b4872a`), com gradiente e glow radial sutil no cabeçalho. > **Regra de ouro para toda animação de entrada aqui: nunca fixar `opacity:0`/`transform` como estilo estático fora de um `@keyframes`, só via `animation: nome duração easing both;`.** Isso garante que `@media print { * { animation: none !important; } }` sozinho devolve o elemento ao estado normal na impressão, sem reset explícito por seletor. Animação nova que não siga isso pode fazer a impressão sair em branco. **Contagem animada dos cards**: `data-count`/`data-final`/`data-format`/`data-color-rule` alimentam `pidDcrAnimaContadores()`, que anima de 0 até o valor com `requestAnimationFrame`, formatando os quadros intermediários com `toLocaleString("pt-BR")`. O texto **final** é sempre `data-final`, a string que os filtros Django já geraram — nunca um valor recalculado em JS. Cor por sinal só onde é seguro: `sign` (ROA/ROE/EBIT/EBITDA — positivo verde, negativo vermelho) e `liquidez` (verde se ≥ 1). **Kanitz, composição/grau de endividamento e IPL não ganham cor nenhuma** — não existe limiar validado para eles, e colorir como "bom"/"ruim" daria falsa segurança num número que o próprio card diz ser estimativa. **Cards só animam depois da aba Resumo ser aberta** (contar um número invisível não faz sentido). Isso cria um risco de impressão: se o usuário nunca abrir a aba e mandar imprimir, a contagem começaria do zero na hora da captura. O **único** listener de `beforeprint` do documento decide entre inicializar tudo já no valor final (aba nunca aberta) ou só finalizar uma contagem em andamento — nunca as duas coisas competindo. **Flip card**: `.dcr-card` é só a cena 3D (`perspective` + `min-height`, necessário porque as duas faces são `position:absolute` e não contribuem para a altura); `.dcr-card-inner` gira no hover do pai; cada face tem `backface-visibility:hidden` e carrega o fundo/borda/sombra/padding. O verso mostra nome + descrição (`"Sem descrição cadastrada."` se vazia) + a fórmula de exibição, centralizados. Como `@media print` zera toda animação, a impressão sempre mostra a frente. > **O relatório é uma foto estática do momento em que foi gerado.** Editar a definição de um indicador depois não atualiza um relatório já aberto ou baixado — precisa clicar "Gerar Relatório" de novo. Isso já gerou uma dúvida ("o card diz 'Sem descrição cadastrada' mas o indicador tem descrição") que não era bug. ### Exportações - **XLSX** (`exportacao.py`, funções puras com openpyxl, recebendo dataclasses `LinhaBalanceteXlsx`/`LinhaDreXlsx`, nunca os models): um botão por seção (Balancete/D.R.E.), `<a href>` puro sem JS. Cabeçalho mesclado (título/empresa/CNPJ/competência), linha de cabeçalho de colunas com fundo roxo, dados a partir da linha 6 com `freeze_panes`. Conta sintética e linha totalizadora ganham negrito + fundo dourado claro. A hierarquia vira `Alignment(indent=nivel)` — não dá para reproduzir toggle numa planilha, então a árvore nasce totalmente expandida. Valores gravados como `float` com `number_format = '"R$" #,##0.00'`, continuam somáveis no Excel. - **PDF do Resumo** (`resumo_pdf.py`, pacote puro, recebe a apuração já carregada e o dict de `_contabil_dados_resumo()`): Resumo do Fechamento + Indicadores + Observações, sem Balancete/D.R.E./Análise Vertical (que já têm o caminho XLSX). Banner roxo/dourado com `logo-branco.png`. O texto rico do contador vira flowables do reportlab (`_resumo_fechamento_flowables()`, via BeautifulSoup): `_inline_markup()` reconstrói `b`/`i`/`u`/`br` aninhados na marcação que o `Paragraph` entende (`strong`→`b`, `em`→`i`), `ul`/`ol` viram `ListFlowable`, e `<img src="data:image/...">` (a única forma que o editor produz) vira um `Image` decodificado de base64 em memória, redimensionado para a largura útil. Seção pulada se o resumo estiver vazio. **`_contabil_dados_resumo(apuracao)`** é compartilhada por `dashboard()` e `resumo_pdf()`: calcula os grupos de indicadores e devolve `observacoes_visiveis` **sem** separar por `alvo_tipo` nem calcular `ancora` (isso é específico do relatório HTML). `dashboard()` faz a separação e as âncoras por cima. ## Riscos conhecidos e armadilhas - **Códigos de classificação fixos** — se um cliente usar plano de contas com numeração diferente, indicadores e a regra de caixa saem errados **silenciosamente**. Revisar contra mais balancetes reais de empresas diferentes antes de confiar cegamente num valor exibido ao cliente. - **Descrições coladas em PDF de fonte atípica** — não têm correção segura; a ferramenta avisa por badge. Valores monetários nunca são afetados. - **Duas cópias mantidas à mão**: os SVGs de ícone (Python + JS), a lógica de árvore/destaque (tela de revisão + relatório) e a montagem do caminho na chave de observação (`chaves.caminhos_linhas()` em Python, `dcAplicaChavesObs()` no JS, mais uma terceira cópia congelada dentro da migração `0078`, que por definição não pode importar código de aplicação). Mudança num lado exige o outro. - **Duas constantes conceituais duplicadas**: `PID_DC_NIVEL_ABERTO_PADRAO` (JS) e `_CONTABIL_NIVEL_ABERTO_PADRAO` (Python), hoje com papéis diferentes (sob demanda na revisão, padrão no relatório). - **`total_achados_pendentes` gera N+1** na listagem de apurações. - **Testar exclusão nesta ViewSet destrói arquivo de verdade**: `perform_destroy()` chama `instance.arquivo.delete(save=False)`, e apagar arquivo do storage **não é revertido** por `transaction.set_rollback(True)` — o padrão de teste usado no resto desta documentação protege só o banco. Validar o caminho "204" contra uma apuração real já custou o PDF anexado dela (o registro voltou pelo rollback, o arquivo não). Nenhum dado analítico depende desse arquivo (`arquivo` não é exposto em serializer nenhum e `reprocessar()` sempre grava um upload novo), mas um teste futuro precisa de apuração descartável ou storage isolado. Ver `[[feedback_rollback_nao_desfaz_arquivo]]` na memória. - **Mudança em `.py` exige reiniciar o `runserver`** para o usuário conseguir testar; mudança só em `.html`/`.css`/`.js` não exige. Isso já mascarou um diagnóstico ("não funciona" que não era bug de código). Ver `[[feedback_py_edit_precisa_restart_runserver]]` na memória.