Melhorias na aplicação Relatório Contábil

This commit is contained in:
Gabriel 2026-09-11 15:35:57 -03:00
parent b0b63d90a5
commit 9752f7c27d
15 changed files with 942 additions and 21 deletions

5
.gitignore vendored
View File

@ -22,8 +22,8 @@ db.sqlite3-journal
*.log
local_settings.py
# Uploads de teste (base local) — planilhas anexadas só pra processar uma
# importação/apuração, não são conteúdo permanente do app (diferente de
# Uploads de teste (base local) — planilhas/PDFs anexados só pra processar
# uma importação/apuração, não são conteúdo permanente do app (diferente de
# media/links_ferramentas/, que são ícones de verdade usados na UI).
# Importações/apurações reais são feitas direto na base de produção, nunca
# sincronizadas via git.
@ -31,6 +31,7 @@ media/planos_saude/operadora/
media/planos_saude/planilha_padrao/
media/indicadores/honorarios/
media/indicadores/tareffa/
media/contabil/apuracoes/
# Distribuição / empacotamento
build/

View File

@ -214,3 +214,17 @@ Pedido explícito do usuário: numa conta/linha com filhos (sintética), marcar
Pedido explícito do usuário: a seção "Demonstração Mensal (Análise Vertical)" do mesmo PDF (páginas finais, histórico de 3 meses com valor+variação percentual por linha, mesma árvore da DRE) — até então ignorada de propósito pelo parser — passou a ser extraída, persistida e exibida. Escopo confirmado por `AskUserQuestion` antes de implementar: aba própria (não embutida na aba D.R.E.) e mesmos recursos por linha que Balancete/D.R.E. (observação inline, tri-state "validado", ocultar do relatório).
Extração validada rodando de fato contra o PDF de referência do usuário (`792 - balancete 072026.pdf`) antes de escrever o parser definitivo — mesmo cuidado de sempre (nunca desenhar regex só de texto colado). Model novo `ContabilLinhaAnaliseVertical` (mesma árvore/descrição/nível da DRE, `valores` como `JSONField` de texto — um `{valor, percentual}` por mês, alinhado por posição com `ContabilApuracao.analise_vertical_meses`) + `ContabilLinhaAnaliseVerticalViewSet` (réplica de `ContabilLinhaDreViewSet`). Nova aba na tela de revisão (`renderAnaliseVertical()`, réplica de `renderDre()` com N colunas dinâmicas de Valor/Variação) e no relatório "Gerar Dashboard" (4ª aba, entre D.R.E. e Resumo) — as duas somem por completo quando a apuração não tem essa seção (relatório antigo). Dois filtros de template novos (`moeda_av`/`percentual_av`) porque o percentual desta seção já vem "pronto" do PDF (não é uma fração como os indicadores). Testado ponta a ponta via `Client.force_login()` contra o PDF real (extração, persistência, PATCH de observação/validado, relatório gerado), sem deixar resíduo em produção. Detalhe completo no `CLAUDE.md` desta pasta.
### 124. Botão "Reprocessar" — anexar um PDF novo pra mesma empresa/competência sem perder observação/validação/achado
Pedido explícito do usuário: até aqui, corrigir uma apuração com o arquivo errado/incompleto exigia excluir e recriar do zero, perdendo toda observação/validação/achado já tratado. Botão novo (ícone ao lado de "Abrir" na lista, só em apuração "Em revisão") abre um modal com um campo de arquivo; o PDF precisa ser da mesma empresa/competência (senão 400 — trocar de empresa é uma análise nova). Escopo confirmado com o usuário: conta/linha sem mudança mantém observação/validado como estavam; conta/linha que mudou volta pra `validado=False` e ganha um alerta visual (campo novo `alterada_reprocessamento`, migração `0068`, nos 3 models de linha); achados **nunca são apagados nem têm status/justificativa sobrescritos** (confirmado via `AskUserQuestion`), mesmo os que não disparam mais com os dados novos — mantém o histórico de tratativa completo.
Decisão de design central: as 4 funções novas de sincronização (`_contabil_sincroniza_contas`/`_linhas_dre`/`_linhas_analise_vertical`/`_achados`, `views.py`) atualizam registros **no lugar** (mesmo `id`), em vez de apagar tudo e recriar como `create()` faz — crucial pra achado nenhum perder a FK pra sua conta. Casamento por `codigo` (Balancete) ou `(descricao, nivel)` (DRE/Análise Vertical, mesma convenção de chave natural já usada pelo histórico de variação em `regras.py`) pras contas/linhas; achados casados por `(regra, código da conta)`. Testado com os 3 casos reais (mesmo arquivo duas vezes — nada muda; arquivo de outra empresa — 400; apuração Concluída — bloqueado) e com um dry-run isolado das 4 funções de sincronização (conta/linha inalterada, alterada, nova, removida + achado que continua e que para de disparar), sem deixar resíduo em produção. Detalhe completo no `CLAUDE.md` desta pasta.
Dois ajustes de UX pedidos pelo usuário depois de ver o badge funcionando: (1) marcar a conta/linha como validada de novo **não limpa mais o alerta** — ele só muda de cor (vermelho → verde), pra dar pra ver depois quais itens já foram reprocessados E revalidados; (2) o tooltip do badge agora mostra o valor de antes do reprocessamento (`valor_anterior_reprocessamento`/`valores_anterior_reprocessamento`, migração `0069`, gravado por `_contabil_sincroniza_*` sempre que a conta/linha muda, `null`/limpo quando não muda). Ambos os campos novos são `read_only` no serializer.
### 125. PDF de fonte atípica — título de seção sem acento + aviso ao contador
Cliente novo (`1751 - Balancete 07.2026.pdf`) deu 400 "Nenhuma linha de DRE encontrada" ao processar. Causa raiz confirmada rodando `pdfplumber` de verdade contra o arquivo: a fonte embutida nesse PDF perde o til do "Ã" ao extrair "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO" (sai "DEMONSTRAÇAO..."), e `parser.py` comparava esse título por igualdade exata — a seção DRE nunca era reconhecida. Corrigido com `_normaliza_titulo()` (remove acento antes de comparar), mesmo espírito de `_RE_PERIODO` já aceitar `Per[ií]odo` pra essa mesma classe de variação de fonte entre clientes/instalações do Questor.
O mesmo PDF também tem algumas descrições de conta com palavras coladas (ex. "BANCÁRIOSA VISTA") — investigado a fundo (medição real dos vãos entre caracteres, tentativa de usar os espaços literais do PDF como sinal), mas não há correção automática segura: o espaçamento dessa fonte é inconsistente a ponto de um vão "dentro de palavra" às vezes ser maior que um vão real "entre palavras". Cheguei a propor um botão de lápis pra edição manual da descrição, mas o usuário suspendeu essa ideia e pediu algo mais simples: `ContabilApuracao.fonte_pdf_atipica` (migração `0070`) fica `True` quando a normalização de acento foi realmente necessária pra reconhecer a seção — sinal indireto de que este PDF usa fonte diferente da de referência, calculado em `extrai_balancete_dre()`/persistido por `create()`/`reprocessar()`. Frontend mostra um ícone de aviso (cor `--gold`) ao lado do nome da empresa, na lista e no cabeçalho da revisão, avisando pra conferir os nomes de conta com atenção — puramente informativo. Validado `True` só no PDF com o problema, `False` nos dois PDFs de referência já confirmados corretos (`792`, `2017`). Detalhe completo no `CLAUDE.md` desta pasta.

View File

@ -335,3 +335,34 @@ Pedido explícito do usuário: a Demonstração Mensal (Análise Vertical), até
Dois filtros de template novos em `contabil_extras.py` — `moeda_av`/`percentual_av` — porque `ContabilLinhaAnaliseVertical.valores` grava valor/percentual como **texto** (não `Decimal`, ver acima), e os filtros `moeda`/`percentual` existentes não servem: `moeda` faria `f"{valor:,.2f}"` falhar contra uma string, e `percentual` multiplicaria por 100 (pensado pra fração, não pra um percentual "já pronto" como o desta seção).
**Validado ponta a ponta via `Client.force_login()` num shell**, contra o PDF de referência real (`792 - balancete 072026.pdf`, empresa 0792/WEITNAUER, competência 07/2026): `POST /api/contabil-apuracoes/` extraiu 154 linhas de Análise Vertical (3 meses: mai/jun/jul de 2026) além das 177 contas/163 linhas de DRE de sempre; `GET .../dashboard/` gerou o relatório com a aba "Análise Vertical" presente; `PATCH` numa linha (observação + `oculta_no_relatorio=False` + `validado=True`) persistiu corretamente e a observação apareceu no relatório gerado em seguida. Toda apuração/arquivo criados durante o teste foram apagados ao final, sem deixar resíduo em produção.
### Reprocessar — anexar um PDF novo pra mesma empresa/competência sem perder observações/achados (rodada 124)
Pedido explícito do usuário: até aqui, corrigir uma apuração com o PDF errado/incompleto exigia excluir e recriar do zero (ver docstring antiga de `concluir()`), perdendo toda observação/validação/achado já registrado. Botão "Reprocessar" novo (ícone ao lado de "Abrir", na lista — só aparece em apuração "Em revisão") abre um modal só com o campo de arquivo; o PDF novo precisa ser da **mesma** `codigo_empresa`+competência (senão 400 — trocar de empresa é uma análise nova, não um reprocessamento). Escopo do que preserva/reseta confirmado com o usuário: contas/linhas sem mudança mantêm observação/validado como estavam; contas/linhas que mudaram voltam pra `validado=False` e ganham um alerta visual; achados **nunca são apagados nem têm status/justificativa sobrescritos**, mesmo os que não disparam mais com os dados novos (confirmado explicitamente via `AskUserQuestion` — é pra manter o histórico completo de tratativa).
**Modelos**: campo novo `alterada_reprocessamento` (`BooleanField`, default `False`) em `ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical` (migração `0068`) — marca que aquela conta/linha mudou no último reprocessamento; o frontend mostra um alerta ao lado do ícone de observação enquanto for `True`. Migração `0069` acrescentou o valor de antes da mudança, só pro tooltip do badge (pedido explícito do usuário — "mostre o valor que estava antes do reprocessamento" ao passar o mouse): `valor_anterior_reprocessamento` (`DecimalField`, `null=True`) em `ContabilConta` (cópia de `saldo_atual`) e `ContabilLinhaDre` (cópia de `valor`); `valores_anterior_reprocessamento` (`JSONField`, mesmo formato de `valores`) em `ContabilLinhaAnaliseVertical`, já que ali não existe um valor único (um por mês). Os três são `read_only` no serializer — só `_contabil_sincroniza_*` grava, nunca um PATCH de cliente.
**`ContabilApuracaoViewSet.reprocessar()`** (`POST /api/contabil-apuracoes/{id}/reprocessar/`, multipart `arquivo`) — bloqueado por `_contabil_garante_em_revisao()` (mesmo gate de qualquer edição; não existe "reprocessar uma apuração Concluída"). Roda o mesmo `dashboard_contabil_pipeline.processa_apuracao()` de `create()`, confere `codigo_empresa`/competência batendo com a apuração existente, troca o `arquivo` (apaga o antigo só **depois** do commit da transação, mesmo cuidado de sempre com storage não-transacional) e delega a resincronização pra 4 funções puras novas — **atualização no lugar (mesmo `id`), não delete+recria**, decisão de design central desta rodada:
- `_contabil_sincroniza_contas()`/`_contabil_sincroniza_linhas_dre()`/`_contabil_sincroniza_linhas_analise_vertical()` — casam cada conta/linha extraída contra a existente (`codigo` pro Balancete; `(descricao, nivel)` pra DRE/Análise Vertical, mesma convenção de chave natural já usada pelo histórico de variação em `regras.py`/`_contabil_monta_historico()` — o par `(descricao, nivel)` desambigua a maioria das descrições repetidas em ramos diferentes da árvore, ex. "COMISSÕES SOBRE VENDAS" aparecendo em mais de um nível, um risco real confirmado contra o PDF de referência). Casada: atualiza os campos brutos **no mesmo registro** (`.save()`, nunca `bulk_create`/delete) — crucial pra achados que referenciam `ContabilConta` nunca perderem a FK; se algum campo relevante mudou, força `validado=False` e `alterada_reprocessamento=True`, guardando o valor de antes em `valor_anterior_reprocessamento`/`valores_anterior_reprocessamento` (sempre lido **antes** de sobrescrever o campo com o valor novo, na mesma função), senão preserva tudo (inclusive limpando esse campo pra `None`/`[]`) como estava. `observacao`/`oculta_no_relatorio` nunca são tocados por essas funções. Sem match na nova extração: `ContabilConta.objects.create()`/equivalente, nasce com os defaults de sempre (`validado=False`, sem o alerta — não tem "antes" pra comparar). Sobra no mapa antigo sem match na nova extração: `.delete()`.
- `_contabil_sincroniza_achados()` — casa por `(regra, código da conta ou None)`. Achado casado: só `titulo`/`mensagem`/`severidade`/`valor_referencia` são atualizados pros valores frescos: `status`/`observacao_contador`/`tratado_por`/`tratado_em`/`oculto_no_relatorio` **nunca** são tocados. Achado sem match na lista fresca (regra não dispara mais): fica **inteiramente intocado** — não é achado "resolvido" nem apagado, continua como estava (histórico). Achado novo: `ContabilAchado.objects.create()` normal, `status="pendente"`.
**Por que atualização no lugar em vez de delete+recria**: a alternativa óbvia (apagar tudo e rodar `bulk_create` como em `create()`) quebraria a FK de todo achado preservado que referencia uma `ContabilConta` (o `on_delete=SET_NULL` desvincularia silenciosamente a conta do achado) — mantendo o mesmo `id` por conta/linha casada, a FK nunca precisa ser tocada, e achados "sobreviventes" continuam apontando pra conta certa sem nenhum código extra de re-vinculação.
**Frontend** (`dashboard-contabil.html`/`.js`): botão de ícone (refresh) ao lado de "Abrir" na lista (`data-dc-reprocessar-abrir`, escondido quando `status === "concluida"`) abre `#dc-reprocessar-modal` (mesmo campo de arquivo de "Nova Análise", reaproveitando `wireArquivoField()` que já era genérico o bastante) — `pidReprocessarApuracaoContabil(id, formData)` chama o endpoint, atualiza a lista e, se a apuração reprocessada é a que já está aberta na revisão, também re-renderiza a tela (`apuracaoAtual = atualizada; renderRevisao()`). Cada uma das 3 tabelas (Balancete/DRE/Análise Vertical) ganhou um badge de alerta (`pidDcAlteradaBadgeHtml()`, ícone de triângulo) ao lado do botão de observação, visível enquanto `alterada_reprocessamento` for `True`. **Marcar a conta/linha como validada de novo NÃO limpa o alerta** (pedido explícito do usuário, revertendo a primeira versão desta rodada, que limpava — "quando o usuário marcar como validado uma conta que foi reprocessada, não deve sumir o ícone de aviso, mas sim, ficar verde... conseguimos verificar quais itens foram reprocessados e revalidados") — o badge muda de cor conforme `validado` da própria conta/linha: `--danger` (vermelho) enquanto pendente, verde (`--validada`, mesmo hex de `.status-pill--ativo`) depois de validado; `pidAtualizarValidadoContaContabil()`/`...LinhaDreContabil()`/`...LinhaAnaliseVerticalContabil()` voltaram a enviar só `{ validado }`, sem tocar em `alterada_reprocessamento`. O campo só é limpo de verdade num próximo reprocessamento sem mudança naquela conta/linha específica (`_contabil_sincroniza_*`, backend). **Tooltip do badge mostra o valor de antes do reprocessamento** (`title`, pedido explícito do usuário): Balancete/DRE formatam `valor_anterior_reprocessamento` direto com `pidDcFormatMoeda()`; Análise Vertical usa `pidDcValorAnteriorAvTexto()`, que junta o valor+percentual de cada mês de `valores_anterior_reprocessamento` (mesmo alinhamento posicional de `analise_vertical_meses`) numa linha por mês dentro do mesmo tooltip. Nenhum badge tem tooltip de valor quando o campo vem `null`/vazio (conta/linha nova nesta apuração, sem "antes" pra comparar).
**Validado com os 3 testes reais**: (1) reprocessar com o **mesmo** arquivo duas vezes seguidas — `validado`/`observacao` de conta/DRE/Análise Vertical preservados, `alterada_reprocessamento` continua `False` em tudo, contagem de linhas idêntica; (2) tentar reprocessar com o arquivo de **outra empresa** — 400 com mensagem explicando a diferença de empresa/competência; (3) tentar reprocessar uma apuração **Concluída** — 400 bloqueado por `_contabil_garante_em_revisao()`. As 4 funções de sincronização também foram testadas isoladamente (dry-run com `transaction.atomic()` + rollback forçado, dados fabricados cobrindo conta/linha inalterada, alterada, nova e removida, mais achado que continua disparando e achado que para de disparar) — todos os casos bateram com o comportamento esperado antes de considerar a implementação pronta. Nenhum resíduo deixado em produção.
### PDF de fonte atípica — título de seção sem acento + aviso ao contador (rodada 125)
**Bug real, encontrado com um PDF de cliente novo** (`1751 - Balancete 07.2026.pdf`, TAROBA INDUSTRIA HOTELEIRA LTDA): `POST /api/contabil-apuracoes/` devolvia 400 "Nenhuma linha de DRE encontrada no PDF" — o `codigo_empresa`/cabeçalho extraía normalmente, só a seção da DRE nunca era reconhecida. Causa raiz, confirmada rodando `pdfplumber` de verdade contra o arquivo (nunca supor a partir de texto colado — ver `[[feedback_pdf_parser_precisa_arquivo_real]]` na memória): a fonte embutida nesse PDF específico (instalação/versão diferente do Questor) perde o til do "Ã" ao extrair "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO" → sai "DEMONSTRAÇAO..." (só falta o til, resto do caractere sai certo — não é um replacement character). `parser.py` comparava esse título por igualdade exata (`texto.startswith(_TITULO_DRE)`), então a seção nunca era detectada.
**Correção**: `_normaliza_titulo()` (novo em `parser.py`, usa `unicodedata.normalize("NFKD", ...)` + remoção de acento) compara o título **sem acento** — `_TITULO_DRE_NORM`/`_TITULO_ANALISE_VERTICAL_NORM` calculados uma vez no import do módulo. Mesmo espírito de `_RE_PERIODO` já aceitar `Per[ií]odo` pra essa mesma classe de variação de fonte entre clientes/instalações.
**Segundo problema, sem correção segura**: o mesmo PDF também tem algumas descrições de conta com palavras coladas (ex. "DEPÓSITOS BANCÁRIOS A VISTA" → "...BANCÁRIOSA VISTA") — o espaçamento entre caracteres dessa fonte varia demais pra um limiar fixo de distância funcionar (`_GAP_ESPACO`, calibrado contra o PDF de referência). Investigação real, não só teórica: (1) medi a distribuição de vãos entre caracteres nesse PDF — o vão "dentro de palavra" (ex. entre "U" e "I" de "EQUIVALENTES") chega a ficar **maior** que um vão real "entre duas palavras curtas" (ex. antes de um "A" sozinho) em alguns pontos, então nenhum limiar único separa os dois casos corretamente; (2) cheguei a cogitar usar os caracteres de espaço literais do próprio PDF como sinal (esse arquivo tem bem menos espaços "de grade" que o de referência), mas descobri que a posição vertical desses espaços às vezes arredonda pra uma linha diferente da do texto real da mesma linha visual, tornando esse sinal não-confiável linha a linha. Testei baixar `_GAP_ESPACO` (ex. pra `0.3`) — conserta alguns casos mas quebra palavras que hoje saem certas (`EQUIVALENTES` vira `EQU IVALENTES`) — decisão explícita do usuário de **não** arriscar essa mudança: valores monetários nunca são afetados, só a descrição de algumas contas, então o custo de regressão supera o benefício.
**Solução adotada — avisar, não tentar corrigir automaticamente** (pedido explícito do usuário, depois de rejeitar a ideia inicial de um botão de lápis pra editar a descrição manualmente — suspenso por enquanto): `ContabilApuracao.fonte_pdf_atipica` (`BooleanField`, migração `0070`) é `True` quando `_normaliza_titulo()` precisou de verdade (o título bateu sem acento mas não bateria com acento) pra reconhecer a seção DRE ou Análise Vertical — sinal indireto mas real de que este PDF usa uma fonte diferente da do relatório de referência, a mesma classe de variação que já se provou capaz de grudar palavras em descrição de conta. `ResultadoExtracao.fonte_pdf_atipica` (novo campo no dataclass, `dashboard_contabil/modelos.py`) carrega o valor calculado em `extrai_balancete_dre()`; `ContabilApuracaoViewSet.create()`/`.reprocessar()` persistem no model. Exposto em `ContabilApuracaoListSerializer`/`ContabilApuracaoDetailSerializer` (`fonte_pdf_atipica`, sem rota de escrita — `http_method_names` do ViewSet nem inclui PATCH/PUT).
**Frontend**: `pidDcFontePdfAtipicaBadgeHtml()` (`dashboard-contabil.js`) — ícone de "i" (mesma forma de `PID_DC_OBSERVACAO_ICONE`, cor `--gold` pra diferenciar visualmente, os dois nunca aparecem lado a lado) ao lado do nome da empresa, tanto na linha da lista quanto no cabeçalho `#dc-review-empresa` da tela de revisão (que passou de `.textContent` pra `.innerHTML`, escapando `codigo_empresa`/`nome_empresa` manualmente com `pidDcEscapeHtml()` já que precisa comportar HTML agora). Tooltip via `title` explica o motivo e pede pra conferir os nomes de conta com atenção — puramente informativo, não bloqueia nem oculta nada.
**Validado contra os 3 arquivos reais disponíveis**: `fonte_pdf_atipica` calculado `True` só pro `1751` (o PDF com o problema), `False` pros PDFs de referência já validados (`792`, `2017`) — sem falso positivo nos dois já confirmados corretos, e a extração completa do `1751` (252 contas, 231 linhas de DRE, Análise Vertical com 3 meses) bate exatamente com o total esperado depois do fix de `_normaliza_titulo()`.

View File

@ -2,7 +2,7 @@
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico (extração do PDF, regras de auditoria, models). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado a pedido do usuário (rótulo visível só, nomes técnicos internos continuam `dashboard_contabil`/`Contabil*`).
Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, variações atípicas mês a mês) — o contador revisa os achados, marca contas/linhas como validadas, registra observações por conta/linha (editor inline na própria tabela, com um toggle "mostrar ao cliente" que nasce desmarcado), escreve um resumo livre do fechamento (considerações/análises, texto rico com imagem) e conclui a análise. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, gráfico de evolução, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa.
Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, variações atípicas mês a mês) — o contador revisa os achados, marca contas/linhas como validadas, registra observações por conta/linha (editor inline na própria tabela, com um toggle "mostrar ao cliente" que nasce desmarcado), escreve um resumo livre do fechamento (considerações/análises, texto rico com imagem) e conclui a análise. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). Se o arquivo original estava errado/incompleto, o botão "Reprocessar" reanexa um PDF novo pra mesma empresa/competência sem perder nada já registrado — conta/linha sem mudança mantém observação e validação, a que mudou volta pra "não validada" com um alerta, e achado já tratado nunca é apagado ou tem sua justificativa sobrescrita. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, gráfico de evolução, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa.
**Ainda fora de escopo**: exportação em XLSX; consolidação entre várias empresas/competências ao mesmo tempo (é sempre uma apuração por vez).

View File

@ -67,6 +67,15 @@ class ResultadoExtracao:
linhas_dre: list[LinhaDreExtraida]
meses_analise_vertical: list[str] = field(default_factory=list)
linhas_analise_vertical: list[LinhaAnaliseVerticalExtraida] = field(default_factory=list)
# `True` quando o título de seção (DRE/Análise Vertical) só bateu depois
# de normalizar acento (ver `_normaliza_titulo`/`fonte_pdf_atipica` em
# `parser.py`) — sinal de que este PDF foi gerado com uma fonte
# diferente da do relatório de referência, o mesmo tipo de variação que
# também pode grudar palavras em descrições de conta (confirmado contra
# `1751 - Balancete 07.2026.pdf`). A view persiste isto em
# `ContabilApuracao.fonte_pdf_atipica`, só pra alertar o contador — não
# bloqueia nem tenta corrigir nada automaticamente.
fonte_pdf_atipica: bool = False
@dataclass

View File

@ -18,6 +18,7 @@ uma quebra de campo/palavra de verdade (validado contra
from __future__ import annotations
import re
import unicodedata
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import IO
@ -43,6 +44,27 @@ _GAP_ESPACO = 0.8
_TITULO_DRE = "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO"
_TITULO_ANALISE_VERTICAL = "Demonstração Mensal (Análise Vertical)"
def _normaliza_titulo(texto: str) -> str:
"""Remove acento antes de comparar título de seção — confirmado contra
`1751 - Balancete 07.2026.pdf` (cliente diferente do PDF de referência
original): a fonte embutida nesse relatório específico perde o til de
"ÇÃO" na extração ("DEMONSTRAÇÃO" vira "DEMONSTRAÇAO", só falta o til do
à — o resto do caractere sai certo, não é um replacement character nem
texto corrompido), o suficiente pra `texto.startswith(_TITULO_DRE)` nunca
bater e a seção DRE nunca ser detectada (`ExtracaoInvalidaError: Nenhuma
linha de DRE encontrada`). Como o relatório é sempre gerado pelo mesmo
sistema (Questor) mas cada cliente/instalação pode embutir uma fonte
ligeiramente diferente, comparar sem acento é mais robusto do que
calibrar um título fixo por cliente — mesmo espírito de `_RE_PERIODO`
já aceitar "Per[ií]odo" pelo mesmo tipo de variação."""
sem_acento = unicodedata.normalize("NFKD", texto).encode("ascii", "ignore").decode("ascii")
return sem_acento.upper()
_TITULO_DRE_NORM = _normaliza_titulo(_TITULO_DRE)
_TITULO_ANALISE_VERTICAL_NORM = _normaliza_titulo(_TITULO_ANALISE_VERTICAL)
_RE_MONETARIO = re.compile(r"\(?-?[\d.]+,\d{2}\)?")
_RE_LINHA_BALANCETE = re.compile(r"^(?P<conta>\d+)\s+(?P<tipo>S)?\s*(?P<codigo>[\d.]+)\s+(?P<resto>.+)$")
_RE_CNPJ = re.compile(r"CNPJ:\s*([\d./-]+)")
@ -205,6 +227,11 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
ordem_av = 0
base_x0_dre: float | None = None
base_x0_av: float | None = None
# Ver ResultadoExtracao.fonte_pdf_atipica — só vira True se a comparação
# SEM acento (texto_norm) bateu onde a comparação COM acento (texto) não
# bateria, ou seja, a normalização foi realmente necessária pra este
# arquivo, não só uma checagem redundante.
fonte_pdf_atipica = False
with pdfplumber.open(origem) as pdf:
if not pdf.pages:
@ -220,8 +247,15 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
texto = linha["texto"].strip()
if not texto:
continue
# Comparado sem acento (ver _normaliza_titulo) — a fonte
# embutida varia entre PDFs de clientes/instalações
# diferentes do Questor e pode perder um til/acento
# específico sem corromper o resto do texto.
texto_norm = _normaliza_titulo(texto)
if texto.startswith(_TITULO_ANALISE_VERTICAL):
if texto_norm.startswith(_TITULO_ANALISE_VERTICAL_NORM):
if not texto.startswith(_TITULO_ANALISE_VERTICAL):
fonte_pdf_atipica = True
if secao != "analise_vertical":
secao = "analise_vertical"
base_x0_av = None
@ -232,7 +266,9 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
# ou da Análise Vertical, que também reimprime esse título —
# sem essa restrição, `secao` voltaria de "analise_vertical"
# pra "dre" a cada quebra de página da Análise Vertical.
if secao in ("cabecalho", "balancete") and texto.startswith(_TITULO_DRE):
if secao in ("cabecalho", "balancete") and texto_norm.startswith(_TITULO_DRE_NORM):
if not texto.startswith(_TITULO_DRE):
fonte_pdf_atipica = True
secao = "dre"
base_x0_dre = None
continue
@ -265,7 +301,7 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
)
)
elif secao == "dre":
if texto.startswith("Valores expressos") or texto.startswith(_TITULO_DRE):
if texto.startswith("Valores expressos") or texto_norm.startswith(_TITULO_DRE_NORM):
continue
valores = _RE_MONETARIO.findall(texto)
if not valores:
@ -343,4 +379,5 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
linhas_dre=linhas_dre,
meses_analise_vertical=meses_analise_vertical or [],
linhas_analise_vertical=linhas_analise_vertical,
fonte_pdf_atipica=fonte_pdf_atipica,
)

View File

@ -0,0 +1,28 @@
# Generated by Django 6.0.7 on 2026-09-11 16:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0067_contabilapuracao_analise_vertical_meses_and_more'),
]
operations = [
migrations.AddField(
model_name='contabilconta',
name='alterada_reprocessamento',
field=models.BooleanField(default=False, verbose_name='Alterada no último reprocessamento'),
),
migrations.AddField(
model_name='contabillinhaanalisevertical',
name='alterada_reprocessamento',
field=models.BooleanField(default=False, verbose_name='Alterada no último reprocessamento'),
),
migrations.AddField(
model_name='contabillinhadre',
name='alterada_reprocessamento',
field=models.BooleanField(default=False, verbose_name='Alterada no último reprocessamento'),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 6.0.7 on 2026-09-11 17:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0068_contabilconta_alterada_reprocessamento_and_more'),
]
operations = [
migrations.AddField(
model_name='contabilconta',
name='valor_anterior_reprocessamento',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=16, null=True, verbose_name='Saldo atual antes do último reprocessamento'),
),
migrations.AddField(
model_name='contabillinhaanalisevertical',
name='valores_anterior_reprocessamento',
field=models.JSONField(blank=True, default=list, verbose_name='Valores mensais antes do último reprocessamento'),
),
migrations.AddField(
model_name='contabillinhadre',
name='valor_anterior_reprocessamento',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=16, null=True, verbose_name='Valor antes do último reprocessamento'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-09-11 18:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0069_contabil_valor_anterior_reprocessamento'),
]
operations = [
migrations.AddField(
model_name='contabilapuracao',
name='fonte_pdf_atipica',
field=models.BooleanField(default=False, verbose_name='PDF de fonte atípica (risco de nomenclatura)'),
),
]

View File

@ -2020,6 +2020,18 @@ class ContabilApuracao(models.Model):
# a apuração. Lista vazia quando o PDF não tinha essa seção (relatório
# mais antigo, ou empresa sem essa seção habilitada no Questor).
analise_vertical_meses = models.JSONField("Meses da Análise Vertical", default=list, blank=True)
# Detectado por `parser.extrai_balancete_dre()` (`fonte_pdf_atipica` no
# `ResultadoExtracao`) e persistido por `create()`/`reprocessar()` —
# marca quando o título de seção (DRE/Análise Vertical) só bateu depois
# de remover acento (`_normaliza_titulo`), sinal de que este PDF usa uma
# fonte diferente da do relatório de referência. Confirmado contra `1751
# - Balancete 07.2026.pdf`: nesse arquivo, o mesmo tipo de variação de
# fonte também gruda palavras em algumas descrições de conta (ex.
# "BANCÁRIOSA VISTA"), sem que exista um jeito confiável de corrigir
# automaticamente (ver plano.md/CLAUDE.md do pacote) — o frontend usa
# este campo só pra mostrar um aviso ao lado do nome da empresa, pedindo
# atenção a esse risco, não pra bloquear nada.
fonte_pdf_atipica = models.BooleanField("PDF de fonte atípica (risco de nomenclatura)", default=False)
class Meta:
verbose_name = "Apuração do Relatório Contábil"
@ -2070,6 +2082,29 @@ class ContabilConta(models.Model):
# botão ao lado do de observação, pra marcar contas já conferidas durante
# a revisão.
validado = models.BooleanField("Validado pelo contador", default=False)
# Marcado por `ContabilApuracaoViewSet.reprocessar()` quando o valor desta
# conta mudou em relação à versão anterior (novo arquivo anexado pro
# mesmo período/empresa) — reprocessar também força `validado=False` de
# volta nesse caso (a conta precisa ser revisada de novo), e o frontend
# mostra um alerta ao lado do ícone de observação enquanto este campo for
# `True`. Marcar a conta como validada de novo NÃO limpa este campo
# (pedido explícito do usuário) — o alerta continua visível, só muda de
# cor, pra dar pra identificar depois quais itens já foram reprocessados
# E revalidados; só um próximo reprocessamento sem mudança nesta conta
# específica limpa de vez. Não afeta contas sem mudança nenhuma (essas
# mantêm `validado` e `observacao` como estavam, ver "Reprocessar" no
# CLAUDE.md do pacote).
alterada_reprocessamento = models.BooleanField("Alterada no último reprocessamento", default=False)
# Cópia de `saldo_atual` de ANTES do reprocessamento que ligou
# `alterada_reprocessamento` — só existe pra alimentar o tooltip do
# badge de alerta no frontend ("valor antes do reprocessamento"), pedido
# explícito do usuário. `None` enquanto `alterada_reprocessamento` for
# `False` (nunca mudou, ou um reprocessamento seguinte não mudou de
# novo — ver `_contabil_sincroniza_contas()` em views.py, que também é
# quem grava este campo).
valor_anterior_reprocessamento = models.DecimalField(
"Saldo atual antes do último reprocessamento", max_digits=16, decimal_places=2, null=True, blank=True
)
class Meta:
verbose_name = "Conta do Balancete (Relatório Contábil)"
@ -2098,6 +2133,13 @@ class ContabilLinhaDre(models.Model):
oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=True)
# Mesmo espírito de ContabilConta.validado.
validado = models.BooleanField("Validado pelo contador", default=False)
# Mesmo espírito de ContabilConta.alterada_reprocessamento.
alterada_reprocessamento = models.BooleanField("Alterada no último reprocessamento", default=False)
# Mesmo espírito de ContabilConta.valor_anterior_reprocessamento, só que
# cópia de `valor` (não tem saldo_atual/saldo_anterior separados aqui).
valor_anterior_reprocessamento = models.DecimalField(
"Valor antes do último reprocessamento", max_digits=16, decimal_places=2, null=True, blank=True
)
class Meta:
verbose_name = "Linha da DRE (Relatório Contábil)"
@ -2128,6 +2170,12 @@ class ContabilLinhaAnaliseVertical(models.Model):
# Mesmo espírito de ContabilLinhaDre.oculta_no_relatorio/.validado.
oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=True)
validado = models.BooleanField("Validado pelo contador", default=False)
# Mesmo espírito de ContabilConta.alterada_reprocessamento.
alterada_reprocessamento = models.BooleanField("Alterada no último reprocessamento", default=False)
# Mesmo espírito de ContabilConta.valor_anterior_reprocessamento — aqui é
# uma cópia da lista `valores` inteira (mesmo formato, um item por mês),
# já que não existe um valor único nesta linha.
valores_anterior_reprocessamento = models.JSONField("Valores mensais antes do último reprocessamento", default=list, blank=True)
class Meta:
verbose_name = "Linha da Análise Vertical (Relatório Contábil)"

View File

@ -2249,22 +2249,78 @@ class ContabilContaSerializer(serializers.ModelSerializer):
"observacao",
"oculta_no_relatorio",
"validado",
"alterada_reprocessamento",
"valor_anterior_reprocessamento",
]
read_only_fields = [
"id",
"conta_numero",
"codigo",
"descricao",
"tipo",
"saldo_anterior",
"debito",
"credito",
"saldo_atual",
"alterada_reprocessamento",
"valor_anterior_reprocessamento",
]
read_only_fields = ["id", "conta_numero", "codigo", "descricao", "tipo", "saldo_anterior", "debito", "credito", "saldo_atual"]
class ContabilLinhaDreSerializer(serializers.ModelSerializer):
class Meta:
model = ContabilLinhaDre
fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador", "observacao", "oculta_no_relatorio", "validado"]
read_only_fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador"]
fields = [
"id",
"ordem",
"descricao",
"nivel",
"valor",
"totalizador",
"observacao",
"oculta_no_relatorio",
"validado",
"alterada_reprocessamento",
"valor_anterior_reprocessamento",
]
read_only_fields = [
"id",
"ordem",
"descricao",
"nivel",
"valor",
"totalizador",
"alterada_reprocessamento",
"valor_anterior_reprocessamento",
]
class ContabilLinhaAnaliseVerticalSerializer(serializers.ModelSerializer):
class Meta:
model = ContabilLinhaAnaliseVertical
fields = ["id", "ordem", "descricao", "nivel", "totalizador", "valores", "observacao", "oculta_no_relatorio", "validado"]
read_only_fields = ["id", "ordem", "descricao", "nivel", "totalizador", "valores"]
fields = [
"id",
"ordem",
"descricao",
"nivel",
"totalizador",
"valores",
"observacao",
"oculta_no_relatorio",
"validado",
"alterada_reprocessamento",
"valores_anterior_reprocessamento",
]
read_only_fields = [
"id",
"ordem",
"descricao",
"nivel",
"totalizador",
"valores",
"alterada_reprocessamento",
"valores_anterior_reprocessamento",
]
class ContabilAchadoSerializer(serializers.ModelSerializer):
@ -2539,6 +2595,7 @@ class ContabilApuracaoListSerializer(serializers.ModelSerializer):
"criado_em",
"concluida_em",
"total_achados_pendentes",
"fonte_pdf_atipica",
]
def get_total_achados_pendentes(self, obj: ContabilApuracao) -> int:
@ -2567,6 +2624,7 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
"criado_em",
"concluida_em",
"resumo_fechamento",
"fonte_pdf_atipica",
"contas",
"linhas_dre",
"analise_vertical_meses",

View File

@ -40,6 +40,7 @@ from .custo_contratacao.pdf import gera_pdf_simulacao
from .dashboard_contabil import exportacao as dashboard_contabil_exportacao
from .dashboard_contabil import formula as dashboard_contabil_formula
from .dashboard_contabil import indicadores as dashboard_contabil_indicadores
from .dashboard_contabil import modelos as dashboard_contabil_modelos
from .dashboard_contabil import pipeline as dashboard_contabil_pipeline
from .templatetags import contabil_extras as dashboard_contabil_extras
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
@ -3712,6 +3713,200 @@ def _contabil_arvore_contexto(
return contexto
def _contabil_sincroniza_contas(
apuracao: ContabilApuracao, contas_extraidas: list[dashboard_contabil_modelos.LinhaBalanceteExtraida]
) -> None:
"""Resincroniza `ContabilConta` a partir de um reprocessamento
(`ContabilApuracaoViewSet.reprocessar()`) — casa pelo `codigo` de
classificação (chave natural já usada pelo histórico de variação em
`regras.py`) e atualiza os registros **no lugar** (mesmo `id`), pra
achados que referenciam essas contas nunca perderem a FK. Conta sem
mudança real mantém `validado`/`alterada_reprocessamento` como estavam;
conta com algum campo divergente da versão anterior volta pra
`validado=False` e `alterada_reprocessamento=True`, guardando o
`saldo_atual` de antes em `valor_anterior_reprocessamento` (só pro
tooltip do badge no frontend). `observacao`/`oculta_no_relatorio` nunca
são tocados aqui."""
antigas = {conta.codigo: conta for conta in apuracao.contas.all()}
vistos: set[str] = set()
for indice, extraida in enumerate(contas_extraidas):
tipo = extraida.tipo or "A"
antiga = antigas.get(extraida.codigo)
if antiga is not None:
vistos.add(extraida.codigo)
alterou = (
antiga.descricao != extraida.descricao
or antiga.tipo != tipo
or antiga.saldo_anterior != extraida.saldo_anterior
or antiga.debito != extraida.debito
or antiga.credito != extraida.credito
or antiga.saldo_atual != extraida.saldo_atual
)
antiga.valor_anterior_reprocessamento = antiga.saldo_atual if alterou else None
antiga.ordem = indice
antiga.conta_numero = extraida.conta_numero
antiga.descricao = extraida.descricao
antiga.tipo = tipo
antiga.saldo_anterior = extraida.saldo_anterior
antiga.debito = extraida.debito
antiga.credito = extraida.credito
antiga.saldo_atual = extraida.saldo_atual
antiga.validado = False if alterou else antiga.validado
antiga.alterada_reprocessamento = alterou
antiga.save()
else:
ContabilConta.objects.create(
apuracao=apuracao,
ordem=indice,
conta_numero=extraida.conta_numero,
codigo=extraida.codigo,
descricao=extraida.descricao,
tipo=tipo,
saldo_anterior=extraida.saldo_anterior,
debito=extraida.debito,
credito=extraida.credito,
saldo_atual=extraida.saldo_atual,
)
for codigo, antiga in antigas.items():
if codigo not in vistos:
antiga.delete()
def _contabil_sincroniza_linhas_dre(
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaDreExtraida]
) -> None:
"""Mesmo espírito de `_contabil_sincroniza_contas()`, sobre
`ContabilLinhaDre` — sem código de classificação, a chave natural é
`(descricao, nivel)` (o par que já desambigua a maioria das descrições
repetidas em ramos diferentes da árvore, ex. "COMISSÕES SOBRE VENDAS"
aparecendo em mais de um nível)."""
antigas = {(linha.descricao, linha.nivel): linha for linha in apuracao.linhas_dre.all()}
vistos: set[tuple[str, int]] = set()
for linha in linhas_extraidas:
chave = (linha.descricao, linha.nivel)
antiga = antigas.get(chave)
if antiga is not None:
vistos.add(chave)
alterou = antiga.totalizador != linha.totalizador or antiga.valor != linha.valor
antiga.valor_anterior_reprocessamento = antiga.valor if alterou else None
antiga.ordem = linha.ordem
antiga.totalizador = linha.totalizador
antiga.valor = linha.valor
antiga.validado = False if alterou else antiga.validado
antiga.alterada_reprocessamento = alterou
antiga.save()
else:
ContabilLinhaDre.objects.create(
apuracao=apuracao,
ordem=linha.ordem,
descricao=linha.descricao,
nivel=linha.nivel,
valor=linha.valor,
totalizador=linha.totalizador,
)
for chave, antiga in antigas.items():
if chave not in vistos:
antiga.delete()
def _contabil_valores_analise_vertical_iguais(
valores_salvos: list[dict[str, str]], valores_novos: list[dashboard_contabil_modelos.ValorMensalAnaliseVertical]
) -> bool:
if len(valores_salvos) != len(valores_novos):
return False
return all(
Decimal(salvo["valor"]) == novo.valor and Decimal(salvo["percentual"]) == novo.percentual
for salvo, novo in zip(valores_salvos, valores_novos)
)
def _contabil_sincroniza_linhas_analise_vertical(
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaAnaliseVerticalExtraida]
) -> None:
"""Mesmo espírito de `_contabil_sincroniza_linhas_dre()`, sobre
`ContabilLinhaAnaliseVertical` — além de `totalizador`, compara a lista
inteira de `valores` (convertendo de volta pra `Decimal`, já que é
gravada como texto no JSONField)."""
antigas = {(linha.descricao, linha.nivel): linha for linha in apuracao.linhas_analise_vertical.all()}
vistos: set[tuple[str, int]] = set()
for linha in linhas_extraidas:
chave = (linha.descricao, linha.nivel)
antiga = antigas.get(chave)
novos_valores = [{"valor": str(v.valor), "percentual": str(v.percentual)} for v in linha.valores]
if antiga is not None:
vistos.add(chave)
alterou = antiga.totalizador != linha.totalizador or not _contabil_valores_analise_vertical_iguais(
antiga.valores, linha.valores
)
antiga.valores_anterior_reprocessamento = antiga.valores if alterou else []
antiga.ordem = linha.ordem
antiga.totalizador = linha.totalizador
antiga.valores = novos_valores
antiga.validado = False if alterou else antiga.validado
antiga.alterada_reprocessamento = alterou
antiga.save()
else:
ContabilLinhaAnaliseVertical.objects.create(
apuracao=apuracao,
ordem=linha.ordem,
descricao=linha.descricao,
nivel=linha.nivel,
totalizador=linha.totalizador,
valores=novos_valores,
)
for chave, antiga in antigas.items():
if chave not in vistos:
antiga.delete()
def _contabil_sincroniza_achados(
apuracao: ContabilApuracao, achados_detectados: list[dashboard_contabil_modelos.AchadoDetectado]
) -> None:
"""Resincroniza `ContabilAchado` a partir de um reprocessamento —
**nunca apaga nem sobrescreve `status`/`observacao_contador`/
`tratado_por`/`tratado_em`/`oculto_no_relatorio`** de um achado já
existente (decisão explícita do usuário: preservar o histórico de
tratativa mesmo que a regra não dispare mais com os dados novos).
Casado por `(regra, código da conta)` — `None` no lugar do código pras
poucas regras gerais sem conta própria (ex. balanceamento Ativo x
Passivo). Um achado cuja combinação não aparece mais em
`achados_detectados` fica intocado (nem "tratado" nem "pendente" muda);
um achado novo nasce `pendente`, do jeito de sempre."""
contas_por_codigo = {conta.codigo: conta for conta in apuracao.contas.all()}
antigos = {
(achado.regra, achado.conta.codigo if achado.conta_id else None): achado
for achado in apuracao.achados.select_related("conta").all()
}
for achado in achados_detectados:
chave = (achado.regra, achado.codigo_conta)
conta = contas_por_codigo.get(achado.codigo_conta) if achado.codigo_conta else None
existente = antigos.get(chave)
if existente is not None:
existente.conta = conta
existente.titulo = achado.titulo
existente.mensagem = achado.mensagem
existente.severidade = achado.severidade
existente.valor_referencia = achado.valor_referencia
existente.save()
else:
ContabilAchado.objects.create(
apuracao=apuracao,
conta=conta,
regra=achado.regra,
severidade=achado.severidade,
titulo=achado.titulo,
mensagem=achado.mensagem,
valor_referencia=achado.valor_referencia,
)
class ContabilApuracaoViewSet(viewsets.ModelViewSet):
"""Dashboard Contábil (Relatórios > Contabilidade) — permissão de toggle
único, mesmo espírito de ImportacaoPlanoSaudeViewSet/IndicadorApuracaoViewSet.
@ -3787,6 +3982,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
arquivo=ContentFile(conteudo, name=arquivo.name),
criado_por=request.user,
analise_vertical_meses=resultado.extracao.meses_analise_vertical,
fonte_pdf_atipica=resultado.extracao.fonte_pdf_atipica,
)
ContabilConta.objects.bulk_create(
[
@ -3860,12 +4056,119 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
serializer = ContabilApuracaoDetailSerializer(apuracao)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
def reprocessar(self, request: Request, pk: str | None = None) -> Response:
"""Reanexa um novo PDF pra **mesma** empresa/competência (ex.: o
arquivo original tinha um erro/estava incompleto) e resincroniza
conta/linha por conta/linha, em vez de excluir e recriar tudo do
zero — pedido explícito do usuário, pra preservar observações e
achados já tratados:
- Conta/linha já existente (casada por `codigo`, no Balancete, ou por
`(descricao, nivel)`, na DRE/Análise Vertical — mesma convenção já
usada pelo histórico de variação em `regras.py`) tem seus campos
brutos (saldo/valor/ordem) atualizados **no mesmo registro**
(mesmo `id`), preservando `observacao`/`oculta_no_relatorio`
sempre. Se algum valor realmente mudou, `validado` volta pra
`False` e `alterada_reprocessamento` vira `True` (frontend mostra
um alerta); se nada mudou, os dois continuam como estavam.
- Conta/linha que só existe na versão nova é criada normalmente
(`alterada_reprocessamento=False` — não tem "antes" pra comparar).
- Conta/linha que só existia na versão antiga (sumiu do PDF novo) é
excluída.
- **Achado nunca é apagado nem tem seu `status`/`observacao_contador`
sobrescrito** (confirmado com o usuário): um achado da mesma
`(regra, código da conta)` que ainda se aplica só tem
título/mensagem/severidade atualizados; um que não se aplica mais
continua exatamente como estava (histórico); um achado novo nasce
`pendente`, do jeito de sempre.
Bloqueado numa apuração "Concluída" (`_contabil_garante_em_revisao`,
mesmo gate de qualquer edição) e numa competência/empresa diferente
da já cadastrada (reprocessar não é pra trocar de empresa — isso é
uma análise nova). Ver `_contabil_sincroniza_*` logo abaixo."""
apuracao = self.get_object()
_contabil_garante_em_revisao(apuracao)
entrada = ContabilApuracaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
arquivo = entrada.validated_data["arquivo"]
conteudo = arquivo.read()
try:
resultado = dashboard_contabil_pipeline.processa_apuracao(
io.BytesIO(conteudo), _contabil_monta_historico
)
except ContabilExtracaoInvalidaError:
return Response(
{
"detail": (
"O arquivo não está no formato esperado (Balancete + DRE do Questor). "
"Contate a Integração e Inovação."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
cabecalho = resultado.extracao.cabecalho
nova_competencia = cabecalho.periodo_fim.replace(day=1)
if cabecalho.codigo_empresa != apuracao.codigo_empresa or nova_competencia != apuracao.competencia:
return Response(
{
"detail": (
f"O arquivo anexado é de {cabecalho.nome_empresa} ({nova_competencia:%m/%Y}), "
f"diferente desta análise ({apuracao.nome_empresa} — {apuracao.competencia:%m/%Y}). "
"Reprocessar exige o mesmo período/empresa — pra outra competência, crie uma nova análise."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
caminho_arquivo_antigo = apuracao.arquivo.name
novo_arquivo_salvo = False
try:
with transaction.atomic():
apuracao.nome_empresa = cabecalho.nome_empresa
apuracao.cnpj = cabecalho.cnpj
apuracao.periodo_inicio = cabecalho.periodo_inicio
apuracao.periodo_fim = cabecalho.periodo_fim
apuracao.analise_vertical_meses = resultado.extracao.meses_analise_vertical
apuracao.fonte_pdf_atipica = resultado.extracao.fonte_pdf_atipica
apuracao.arquivo = ContentFile(conteudo, name=arquivo.name)
apuracao.save()
novo_arquivo_salvo = True
_contabil_sincroniza_contas(apuracao, resultado.extracao.contas)
_contabil_sincroniza_linhas_dre(apuracao, resultado.extracao.linhas_dre)
_contabil_sincroniza_linhas_analise_vertical(apuracao, resultado.extracao.linhas_analise_vertical)
_contabil_sincroniza_achados(apuracao, resultado.achados)
except Exception:
if novo_arquivo_salvo:
apuracao.arquivo.delete(save=False)
return Response(
{"detail": "Não foi possível reprocessar a análise. Contate a Integração e Inovação."},
status=status.HTTP_400_BAD_REQUEST,
)
# Só chega aqui se a transação inteira deu certo — o arquivo antigo
# não é mais referenciado por nenhuma linha do banco, então é seguro
# apagar seu conteúdo físico agora (não é transacional, por isso o
# cuidado de só apagar depois do commit).
if caminho_arquivo_antigo and caminho_arquivo_antigo != apuracao.arquivo.name:
apuracao.arquivo.storage.delete(caminho_arquivo_antigo)
apuracao = ContabilApuracao.objects.prefetch_related(
"contas", "linhas_dre", "linhas_analise_vertical", "achados__conta"
).get(pk=apuracao.pk)
return Response(ContabilApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"])
def concluir(self, request: Request, pk: str | None = None) -> Response:
"""Trava a apuração pra edição (observações/achados) — mesmo espírito
de `ImportacaoPlanoSaude.status="concluida"`. Não há "reabrir" nesta
v1 (diferente de planos_saude): reprocessar exige excluir e reanexar
o PDF."""
v1 (diferente de planos_saude) — `reprocessar()` acima é o jeito de
corrigir uma apuração ainda "Em revisão" com um PDF novo, sem
perder observações/achados já tratados."""
apuracao = self.get_object()
apuracao.status = ContabilApuracao.STATUS_CONCLUIDA
apuracao.concluida_em = timezone.now()

View File

@ -759,6 +759,45 @@
opacity: 0.5;
}
/* Badge de alerta — conta/linha que mudou no último reprocessamento
(`alterada_reprocessamento`, ver "Reprocessar" no CLAUDE.md do pacote).
Puramente informativo (não é um botão, só um `<span>` com tooltip via
`title`) — continua visível mesmo depois do contador marcar a conta/linha
como validada de novo (pedido explícito do usuário: precisa dar pra
identificar quais itens já foram reprocessados E revalidados), só troca
de cor via `--validada` abaixo; só some de vez num próximo reprocessamento
sem essa mudança específica. Cor `--danger` (pendente), igual à
severidade "alta" dos achados — chama mais atenção que o dourado já usado
pelo estado "parcial" do validado. */
.dc-conta-alterada-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
color: var(--danger);
}
/* Mesmo verde de .status-pill--ativo (perfis-acesso.css) — "já revisado e
confirmado", não é um token de tema, é o hex fixo já reaproveitado em
outras telas pro mesmo significado. */
.dc-conta-alterada-badge--validada {
color: #2f9e6e;
}
/* Aviso de "fonte de PDF atípica" (ContabilApuracao.fonte_pdf_atipica) ao
lado do nome da empresa — mesmo espírito de .dc-conta-alterada-badge
(span informativo, tooltip via `title`, não é um botão), cor `--gold`
porque é um aviso de atenção, não um alerta de severidade alta como o
badge de alteração acima. */
.dc-fonte-atipica-badge {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
color: var(--gold);
}
/* Editor inline de observação — substitui o antigo modal `#dc-observacao-modal`
(pedido explícito do usuário, pra não perder o texto por causa de um
clique acidental fora de um popup): uma linha extra logo abaixo da conta/
@ -1385,3 +1424,56 @@
color: var(--accent);
text-decoration: underline;
}
/* Botão flutuante "voltar ao topo" — fixo na viewport (não escopado a
nenhuma aba), aparece via JS (dashboard-contabil.js, evento `scroll`)
só depois que o início da página já saiu de vista. `bottom`/`right`
dão folga suficiente pra não sobrepor nenhum outro elemento fixo da
página (não há nenhum hoje, mas evita colidir se um for adicionado). */
.dc-back-to-top-btn {
position: fixed;
right: var(--space-5);
bottom: var(--space-5);
z-index: 20;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--accent);
color: var(--accent-contrast);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
transition: background var(--transition-fast), transform 100ms ease;
}
.dc-back-to-top-btn:hover {
background: var(--accent-hover);
}
.dc-back-to-top-btn:active {
transform: scale(0.92);
}
/* Cabeçalho de tabela com o botão "Restaurar formatação padrão" (Balancete/
DRE/Análise Vertical) — a última coluna (Observação) vira um flex pra
manter o rótulo à esquerda e o botão colado na borda direita da linha de
cabeçalho, sem precisar de uma coluna extra (e sem mexer nos `colspan`
das linhas vazias/observação, que continuam contando só as colunas de
dado). */
.dc-th-linha {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
/* Menor que o .icon-btn padrão (38px) — vive dentro de uma célula de
cabeçalho de tabela, não pode competir em tamanho com o texto da coluna. */
.dc-reset-formatacao-btn {
width: 22px;
height: 22px;
flex-shrink: 0;
color: var(--text-muted);
}

View File

@ -169,6 +169,10 @@ async function pidConcluirApuracaoContabil(id) {
return pidApiRequest(`/contabil-apuracoes/${id}/concluir/`, { method: "POST" });
}
async function pidReprocessarApuracaoContabil(id, formData) {
return pidApiRequest(`/contabil-apuracoes/${id}/reprocessar/`, { method: "POST", body: formData });
}
/**
* /dashboard/ é GET (não POST, diferente do padrão /gerar/ de outras
* ferramentas) exatamente pra poder ser aberto assim — navegação de
@ -195,6 +199,12 @@ async function pidAtualizarObservacaoLinhaDreContabil(id, payload) {
return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: payload });
}
// `alterada_reprocessamento` nunca é tocado por aqui (pedido explícito do
// usuário) — o badge de alerta continua visível mesmo depois de validado,
// só muda de cor (ver pidDcAlteradaBadgeHtml), pra o contador conseguir
// identificar depois quais itens já foram reprocessados E revalidados. Só
// um reprocessamento novo (sem mudança daquela conta/linha desta vez) limpa
// o campo de verdade, em `_contabil_sincroniza_*` (views.py).
async function pidAtualizarValidadoContaContabil(id, validado) {
return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { validado } });
}
@ -310,6 +320,22 @@ document.addEventListener("DOMContentLoaded", async () => {
if (contentEl) contentEl.hidden = !canUse;
if (!canUse) return;
// ---- Botão "voltar ao topo" ----
// Página tem tabelas longas (Balancete/DRE/Análise Vertical, achados) —
// botão flutuante só aparece depois que o início da página já saiu da
// viewport (scroll da própria janela, `.page-content` não tem overflow
// próprio), pra não poluir a tela quando não faz falta.
const PID_DC_BACK_TO_TOP_LIMIAR_PX = 400;
const backToTopBtn = document.getElementById("dc-back-to-top-btn");
if (backToTopBtn) {
window.addEventListener("scroll", () => {
backToTopBtn.hidden = window.scrollY < PID_DC_BACK_TO_TOP_LIMIAR_PX;
});
backToTopBtn.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
}
const formView = document.getElementById("dc-form-view");
const reviewView = document.getElementById("dc-review-view");
@ -368,7 +394,7 @@ document.addEventListener("DOMContentLoaded", async () => {
apuracoes.forEach((apuracao) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)}</td>
<td>${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)}</td>
<td>${pidDcFormatCompetencia(apuracao.competencia)}</td>
<td>${apuracao.total_achados_pendentes}</td>
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status}</span></td>
@ -376,6 +402,13 @@ document.addEventListener("DOMContentLoaded", async () => {
<td>${pidDcFormatData(apuracao.criado_em)}</td>
<td class="pa-row-actions">
<button type="button" class="btn-outline" data-dc-abrir="${apuracao.id}">Abrir</button>
${
apuracao.status === "concluida"
? ""
: `<button type="button" class="icon-btn" data-dc-reprocessar-abrir="${apuracao.id}" aria-label="Reprocessar" title="Reprocessar (anexar outro arquivo pra esta mesma competência)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
</button>`
}
<button type="button" class="icon-btn" data-dc-excluir="${apuracao.id}" aria-label="Excluir" title="Excluir">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
@ -397,6 +430,75 @@ document.addEventListener("DOMContentLoaded", async () => {
await pidExcluirApuracaoContabil(Number(excluirBtn.getAttribute("data-dc-excluir")));
await carregarLista();
}
const reprocessarBtn = event.target.closest("[data-dc-reprocessar-abrir]");
if (reprocessarBtn) {
abrirReprocessarModal(Number(reprocessarBtn.getAttribute("data-dc-reprocessar-abrir")));
}
});
// ---- Reprocessar (anexar outro PDF pra mesma empresa/competência) ----
// Diferente de "Nova Análise" (cria uma apuração do zero), reprocessar
// resincroniza a apuração já existente — o backend (`_contabil_sincroniza_*`
// em views.py) preserva observação/validação de conta/linha sem mudança e
// só reseta validado + marca `alterada_reprocessamento` no que realmente
// mudou; achados nunca são apagados nem têm status/justificativa
// sobrescritos, mesmo os que não disparam mais. Aberto direto da lista
// (não precisa abrir a apuração antes), por isso tem seu próprio `id` em
// vez de depender de `apuracaoAtual`.
const resetReprocessarArquivoField = wireArquivoField("dc-reprocessar-arquivo");
let dcReprocessarApuracaoId = null;
function abrirReprocessarModal(id) {
dcReprocessarApuracaoId = id;
document.getElementById("dc-reprocessar-arquivo").value = "";
resetReprocessarArquivoField();
document.getElementById("dc-reprocessar-error").textContent = "";
document.getElementById("dc-reprocessar-modal").hidden = false;
}
function fecharReprocessarModal() {
dcReprocessarApuracaoId = null;
document.getElementById("dc-reprocessar-modal").hidden = true;
}
document.getElementById("dc-reprocessar-cancel-btn").addEventListener("click", fecharReprocessarModal);
document.getElementById("dc-reprocessar-modal").addEventListener("click", (event) => {
if (event.target.id === "dc-reprocessar-modal") fecharReprocessarModal();
});
document.getElementById("dc-reprocessar-confirmar-btn").addEventListener("click", async () => {
const errorEl = document.getElementById("dc-reprocessar-error");
errorEl.textContent = "";
const arquivo = document.getElementById("dc-reprocessar-arquivo").files[0];
if (!arquivo) {
errorEl.textContent = "Anexe o PDF de Balancete + DRE.";
return;
}
const formData = new FormData();
formData.append("arquivo", arquivo);
const confirmarBtn = document.getElementById("dc-reprocessar-confirmar-btn");
confirmarBtn.disabled = true;
try {
const atualizada = await pidReprocessarApuracaoContabil(dcReprocessarApuracaoId, formData);
fecharReprocessarModal();
await carregarLista();
// Se a apuração reprocessada é a que já está aberta na tela de
// revisão, atualiza ela também — senão o contador continuaria vendo
// dados desatualizados até sair e reabrir.
if (apuracaoAtual && apuracaoAtual.id === atualizada.id) {
apuracaoAtual = atualizada;
renderRevisao();
}
await pidAlert("Análise reprocessada com sucesso.");
} catch (e) {
errorEl.textContent = e.message;
} finally {
confirmarBtn.disabled = false;
}
});
// ---- Nova Análise ----
@ -479,7 +581,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const apuracao = apuracaoAtual;
const concluida = apuracao.status === "concluida";
document.getElementById("dc-review-empresa").textContent = `— ${apuracao.codigo_empresa} - ${apuracao.nome_empresa}`;
document.getElementById("dc-review-empresa").innerHTML =
`— ${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)}`;
document.getElementById("dc-review-subtitle").textContent =
`CNPJ ${apuracao.cnpj || "não informado"} · Competência ${pidDcFormatCompetencia(apuracao.competencia)} · ${apuracao.contas.length} conta(s)`;
document.getElementById("dc-concluida-aviso").hidden = !concluida;
@ -1117,6 +1220,65 @@ document.addEventListener("DOMContentLoaded", async () => {
const PID_DC_VALIDADO_ICONE =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
// Badge de alerta ao lado dos botões de validado/observação — marca uma
// conta/linha que mudou no último reprocessamento (`alterada_reprocessamento`,
// ver `ContabilApuracaoViewSet.reprocessar()`/`_contabil_sincroniza_*` em
// views.py). Puramente informativo (não clicável) — permanece visível
// mesmo depois do contador marcar a conta/linha como validada de novo
// (pedido explícito do usuário: precisa dar pra identificar depois quais
// itens já foram reprocessados E revalidados, não só sumir o aviso), só
// muda de cor (`--danger` enquanto pendente, verde quando já validado) —
// some de vez só num próximo reprocessamento sem essa mudança específica.
const PID_DC_ALTERADA_ICONE =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><path d="M12 9v4M12 17h.01"/></svg>';
// `valorAnteriorTexto` (já formatado pelo chamador — pidDcFormatMoeda() pra
// Balancete/DRE, pidDcValorAnteriorAvTexto() pra Análise Vertical) some no
// tooltip pra visualização rápida no hover, sem precisar abrir a apuração
// anterior — pedido explícito do usuário. `null` quando o backend não tem
// o valor anterior salvo (conta/linha nova, sem "antes" pra comparar).
function pidDcAlteradaBadgeHtml(alterada, validado, valorAnteriorTexto) {
if (!alterada) return "";
const classe = validado ? "dc-conta-alterada-badge dc-conta-alterada-badge--validada" : "dc-conta-alterada-badge";
const situacao = validado
? "Esta conta/linha mudou no último reprocessamento e já foi revalidada."
: "Esta conta/linha mudou no último reprocessamento e voltou a ficar não validada.";
const titulo = valorAnteriorTexto ? `${situacao}\nValor antes do reprocessamento: ${valorAnteriorTexto}` : situacao;
return `<span class="${classe}" title="${pidDcEscapeHtml(titulo)}">${PID_DC_ALTERADA_ICONE}</span>`;
}
// Só usado pela Análise Vertical (Balancete/DRE têm um valor único —
// formatam com pidDcFormatMoeda() direto no chamador) — junta o valor
// antes do reprocessamento de cada mês (mesmo alinhamento posicional de
// ContabilApuracao.analise_vertical_meses/linha.valores, ver o model) numa
// linha por mês, uma por `\n` (o atributo `title` já quebra linha nisso).
function pidDcValorAnteriorAvTexto(linha) {
const anteriores = linha.valores_anterior_reprocessamento;
if (!anteriores || !anteriores.length) return null;
const meses = apuracaoAtual.analise_vertical_meses || [];
return anteriores
.map((v, i) => `${pidDcFormatMesAnaliseVertical(meses[i])}: ${pidDcFormatMoeda(v.valor)} (${pidDcFormatPercentualAnaliseVertical(v.percentual)})`)
.join("\n");
}
// Aviso de "fonte de PDF atípica" (ContabilApuracao.fonte_pdf_atipica,
// detectado em parser.py — ver _normaliza_titulo()/fonte_pdf_atipica) ao
// lado do nome da empresa, tanto na lista quanto no cabeçalho da revisão.
// Puramente informativo: avisa que esse PDF específico usa uma fonte
// diferente da do relatório de referência, o que já causou palavras
// grudadas em algumas descrições de conta em pelo menos um caso real
// (confirmado contra `1751 - Balancete 07.2026.pdf`) — não há correção
// automática segura pra isso (ver CLAUDE.md do pacote), então o jeito é
// avisar o contador pra conferir com atenção os nomes de conta dessa
// apuração específica.
const PID_DC_FONTE_ATIPICA_ICONE =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
function pidDcFontePdfAtipicaBadgeHtml(fontePdfAtipica) {
if (!fontePdfAtipica) return "";
return `<span class="dc-fonte-atipica-badge" title="Este relatório foi gerado a partir de um modelo de PDF diferente do padrão — pequenos erros de nomenclatura podem ocorrer em algumas contas/linhas devido à fonte usada neste documento. Vale conferir os nomes com atenção.">${PID_DC_FONTE_ATIPICA_ICONE}</span>`;
}
function renderContas() {
const body = document.getElementById("dc-contas-body");
body.innerHTML = "";
@ -1170,6 +1332,11 @@ document.addEventListener("DOMContentLoaded", async () => {
<button type="button" class="dc-conta-observacao-btn${conta.observacao ? " dc-conta-observacao-btn--preenchida" : ""}" data-dc-conta-observacao="${conta.id}" title="${pidDcEscapeHtml(conta.observacao || "Adicionar observação")}" aria-label="${conta.observacao ? "Editar observação" : "Adicionar observação"}">
${PID_DC_OBSERVACAO_ICONE}
</button>
${pidDcAlteradaBadgeHtml(
conta.alterada_reprocessamento,
conta.validado,
conta.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(conta.valor_anterior_reprocessamento) : null
)}
</div>
</td>
`;
@ -1326,6 +1493,11 @@ document.addEventListener("DOMContentLoaded", async () => {
<button type="button" class="dc-conta-observacao-btn${linha.observacao ? " dc-conta-observacao-btn--preenchida" : ""}" data-dc-dre-observacao="${linha.id}" title="${pidDcEscapeHtml(linha.observacao || "Adicionar observação")}" aria-label="${linha.observacao ? "Editar observação" : "Adicionar observação"}">
${PID_DC_OBSERVACAO_ICONE}
</button>
${pidDcAlteradaBadgeHtml(
linha.alterada_reprocessamento,
linha.validado,
linha.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(linha.valor_anterior_reprocessamento) : null
)}
</div>
</td>
`;
@ -1446,7 +1618,13 @@ document.addEventListener("DOMContentLoaded", async () => {
return `<th>${label} — Valor</th><th>${label} — Variação</th>`;
})
.join("")}
<th>Observação</th>
<th>
<span class="dc-th-linha">Observação
<button type="button" class="icon-btn dc-reset-formatacao-btn" data-dc-reset-formatacao="analise-vertical" aria-label="Restaurar formatação padrão da Análise Vertical" title="Restaurar formatação padrão (recolher grupos como no início)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>
</button>
</span>
</th>
`;
}
@ -1508,6 +1686,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<button type="button" class="dc-conta-observacao-btn${linha.observacao ? " dc-conta-observacao-btn--preenchida" : ""}" data-dc-av-observacao="${linha.id}" title="${pidDcEscapeHtml(linha.observacao || "Adicionar observação")}" aria-label="${linha.observacao ? "Editar observação" : "Adicionar observação"}">
${PID_DC_OBSERVACAO_ICONE}
</button>
${pidDcAlteradaBadgeHtml(linha.alterada_reprocessamento, linha.validado, pidDcValorAnteriorAvTexto(linha))}
</div>
</td>
`;
@ -1626,6 +1805,39 @@ document.addEventListener("DOMContentLoaded", async () => {
if (tab === "dashboard") await renderDashboardTab();
});
// ---- Restaurar formatação padrão (Balancete/DRE/Análise Vertical) ----
// Botão no canto direito da linha de cabeçalho de cada tabela — descarta
// todo expandir/recolher manual feito pelo contador e recalcula o mesmo
// estado inicial de dcColapsoPadrao()/dcUltimaLevaVisivel() usado quando a
// apuração é aberta (ver renderRevisao). Cada árvore (Balancete/DRE/Análise
// Vertical) tem seu próprio botão porque cada uma tem seu próprio estado de
// colapso/destaque, independente das demais.
document.getElementById("dc-contas-thead").addEventListener("click", (event) => {
if (!event.target.closest("[data-dc-reset-formatacao]")) return;
dcContasColapsadas = dcColapsoPadrao(apuracaoAtual.contas, dcContaNivel);
dcContasDestaque = dcUltimaLevaVisivel(apuracaoAtual.contas, dcContaNivel, dcContasColapsadas);
dcContasDestaqueHistorico = [];
renderContas();
});
document.getElementById("dc-dre-thead").addEventListener("click", (event) => {
if (!event.target.closest("[data-dc-reset-formatacao]")) return;
const nivelFn = (linha) => Math.max(0, linha.nivel);
dcDreColapsadas = dcColapsoPadrao(apuracaoAtual.linhas_dre, nivelFn);
dcDreDestaque = dcUltimaLevaVisivel(apuracaoAtual.linhas_dre, nivelFn, dcDreColapsadas);
dcDreDestaqueHistorico = [];
renderDre();
});
document.getElementById("dc-av-thead").addEventListener("click", (event) => {
if (!event.target.closest("[data-dc-reset-formatacao]")) return;
const nivelFn = (linha) => Math.max(0, linha.nivel);
dcAvColapsadas = dcColapsoPadrao(apuracaoAtual.linhas_analise_vertical, nivelFn);
dcAvDestaque = dcUltimaLevaVisivel(apuracaoAtual.linhas_analise_vertical, nivelFn, dcAvColapsadas);
dcAvDestaqueHistorico = [];
renderAnaliseVertical();
});
// ---- Resumo do Fechamento (texto rico, aba "Dashboard") ----
// Editor contenteditable + colar/arrastar imagem embutida — mesmo padrão
// de `.ag-richtext` (acessos-gerais.js) e `.ajuda-modal__editor`

View File

@ -448,7 +448,7 @@
<div class="pa-tab-panel" data-dc-panel="balancete" hidden>
<div class="pa-table-wrap">
<table class="pa-table dc-contas-table">
<thead>
<thead id="dc-contas-thead">
<tr>
<th>Conta</th>
<th>Descrição</th>
@ -456,7 +456,13 @@
<th>Débito</th>
<th>Crédito</th>
<th>Saldo Atual</th>
<th>Observação</th>
<th>
<span class="dc-th-linha">Observação
<button type="button" class="icon-btn dc-reset-formatacao-btn" data-dc-reset-formatacao="balancete" aria-label="Restaurar formatação padrão do Balancete" title="Restaurar formatação padrão (recolher grupos como no início)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>
</button>
</span>
</th>
</tr>
</thead>
<tbody id="dc-contas-body"></tbody>
@ -472,11 +478,17 @@
<div class="pa-tab-panel" data-dc-panel="dre" hidden>
<div class="pa-table-wrap">
<table class="pa-table dc-dre-table">
<thead>
<thead id="dc-dre-thead">
<tr>
<th>Descrição</th>
<th>Valor</th>
<th>Observação</th>
<th>
<span class="dc-th-linha">Observação
<button type="button" class="icon-btn dc-reset-formatacao-btn" data-dc-reset-formatacao="dre" aria-label="Restaurar formatação padrão da DRE" title="Restaurar formatação padrão (recolher grupos como no início)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>
</button>
</span>
</th>
</tr>
</thead>
<tbody id="dc-dre-body"></tbody>
@ -492,7 +504,7 @@
<div class="pa-tab-panel" data-dc-panel="analise-vertical" hidden>
<div class="pa-table-wrap">
<table class="pa-table dc-dre-table dc-av-table">
<thead>
<thead id="dc-av-thead">
<tr id="dc-av-head-row">
<th>Descrição</th>
</tr>
@ -544,6 +556,32 @@
</div>
</div>
<div class="modal-overlay" id="dc-reprocessar-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Reprocessar análise</h2>
<p class="modal-card__subtitle">Anexe um PDF novo pra mesma empresa/competência (ex.: o arquivo original estava incompleto ou com erro). Contas/linhas sem mudança mantêm observação e validação como estavam; o que mudou volta pra "não validado" e ganha um alerta.</p>
<div class="modal-field">
<label>Balancete + DRE (PDF)</label>
<div class="dc-file-field">
<label class="btn-outline dc-file-field__btn" for="dc-reprocessar-arquivo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Selecionar arquivo
</label>
<input type="file" id="dc-reprocessar-arquivo" accept=".pdf" class="dc-file-field__input" />
<span class="dc-file-field__name" id="dc-reprocessar-arquivo-name">Nenhum arquivo selecionado</span>
<button type="button" class="icon-btn dc-file-field__remove" id="dc-reprocessar-arquivo-remove" aria-label="Remover arquivo" title="Remover arquivo" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<p class="modal-error" id="dc-reprocessar-error"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="dc-reprocessar-cancel-btn">Cancelar</button>
<button type="button" class="btn-solid" id="dc-reprocessar-confirmar-btn">Reprocessar</button>
</div>
</div>
</div>
<div class="modal-overlay" id="dc-achado-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title" id="dc-achado-modal-titulo"></h2>
@ -771,6 +809,10 @@
</div>
</div>
<button type="button" class="dc-back-to-top-btn" id="dc-back-to-top-btn" aria-label="Voltar ao início da página" title="Voltar ao início da página" hidden>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>
</button>
<div class="modal-overlay" id="calendario-depaula-modal" hidden>
<div class="modal-card iframe-modal-card">
<div class="iframe-modal-header">