From 9752f7c27dfc8fde42c20891b367cbf5850bf67d Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 11 Sep 2026 15:35:57 -0300 Subject: [PATCH] =?UTF-8?q?Melhorias=20na=20aplica=C3=A7=C3=A3o=20Relat?= =?UTF-8?q?=C3=B3rio=20Cont=C3=A1bil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- portal_api/dashboard_contabil/CHANGELOG.md | 14 + portal_api/dashboard_contabil/CLAUDE.md | 31 ++ portal_api/dashboard_contabil/README.md | 2 +- portal_api/dashboard_contabil/modelos.py | 9 + portal_api/dashboard_contabil/parser.py | 43 ++- ...conta_alterada_reprocessamento_and_more.py | 28 ++ ...contabil_valor_anterior_reprocessamento.py | 28 ++ .../0070_contabil_fonte_pdf_atipica.py | 18 + portal_api/models.py | 48 +++ portal_api/serializers.py | 68 +++- portal_api/views.py | 307 +++++++++++++++++- static/css/dashboard-contabil.css | 92 ++++++ static/js/dashboard-contabil.js | 218 ++++++++++++- templates/dashboard-contabil.html | 52 ++- 15 files changed, 942 insertions(+), 21 deletions(-) create mode 100644 portal_api/migrations/0068_contabilconta_alterada_reprocessamento_and_more.py create mode 100644 portal_api/migrations/0069_contabil_valor_anterior_reprocessamento.py create mode 100644 portal_api/migrations/0070_contabil_fonte_pdf_atipica.py diff --git a/.gitignore b/.gitignore index e85e880..97e55c6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/portal_api/dashboard_contabil/CHANGELOG.md b/portal_api/dashboard_contabil/CHANGELOG.md index b1df3ab..cb06dd0 100644 --- a/portal_api/dashboard_contabil/CHANGELOG.md +++ b/portal_api/dashboard_contabil/CHANGELOG.md @@ -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. diff --git a/portal_api/dashboard_contabil/CLAUDE.md b/portal_api/dashboard_contabil/CLAUDE.md index 8e2b6dc..c2a3b9a 100644 --- a/portal_api/dashboard_contabil/CLAUDE.md +++ b/portal_api/dashboard_contabil/CLAUDE.md @@ -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()`. diff --git a/portal_api/dashboard_contabil/README.md b/portal_api/dashboard_contabil/README.md index 8a5ea7e..370d583 100644 --- a/portal_api/dashboard_contabil/README.md +++ b/portal_api/dashboard_contabil/README.md @@ -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). diff --git a/portal_api/dashboard_contabil/modelos.py b/portal_api/dashboard_contabil/modelos.py index 13c0eb8..e8beb33 100644 --- a/portal_api/dashboard_contabil/modelos.py +++ b/portal_api/dashboard_contabil/modelos.py @@ -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 diff --git a/portal_api/dashboard_contabil/parser.py b/portal_api/dashboard_contabil/parser.py index 5b7e523..a03de8c 100644 --- a/portal_api/dashboard_contabil/parser.py +++ b/portal_api/dashboard_contabil/parser.py @@ -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\d+)\s+(?PS)?\s*(?P[\d.]+)\s+(?P.+)$") _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, ) diff --git a/portal_api/migrations/0068_contabilconta_alterada_reprocessamento_and_more.py b/portal_api/migrations/0068_contabilconta_alterada_reprocessamento_and_more.py new file mode 100644 index 0000000..92ac145 --- /dev/null +++ b/portal_api/migrations/0068_contabilconta_alterada_reprocessamento_and_more.py @@ -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'), + ), + ] diff --git a/portal_api/migrations/0069_contabil_valor_anterior_reprocessamento.py b/portal_api/migrations/0069_contabil_valor_anterior_reprocessamento.py new file mode 100644 index 0000000..d12053d --- /dev/null +++ b/portal_api/migrations/0069_contabil_valor_anterior_reprocessamento.py @@ -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'), + ), + ] diff --git a/portal_api/migrations/0070_contabil_fonte_pdf_atipica.py b/portal_api/migrations/0070_contabil_fonte_pdf_atipica.py new file mode 100644 index 0000000..7eda730 --- /dev/null +++ b/portal_api/migrations/0070_contabil_fonte_pdf_atipica.py @@ -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)'), + ), + ] diff --git a/portal_api/models.py b/portal_api/models.py index a8f54ad..66aff65 100644 --- a/portal_api/models.py +++ b/portal_api/models.py @@ -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)" diff --git a/portal_api/serializers.py b/portal_api/serializers.py index 17e3ad3..fafaca0 100644 --- a/portal_api/serializers.py +++ b/portal_api/serializers.py @@ -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", diff --git a/portal_api/views.py b/portal_api/views.py index b9b28ba..e35dad3 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -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() diff --git a/static/css/dashboard-contabil.css b/static/css/dashboard-contabil.css index 28be3e3..24a24a3 100644 --- a/static/css/dashboard-contabil.css +++ b/static/css/dashboard-contabil.css @@ -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 `` 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); +} diff --git a/static/js/dashboard-contabil.js b/static/js/dashboard-contabil.js index cf035fc..542d855 100644 --- a/static/js/dashboard-contabil.js +++ b/static/js/dashboard-contabil.js @@ -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 = ` - ${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} + ${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)} ${pidDcFormatCompetencia(apuracao.competencia)} ${apuracao.total_achados_pendentes} ${PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status} @@ -376,6 +402,13 @@ document.addEventListener("DOMContentLoaded", async () => { ${pidDcFormatData(apuracao.criado_em)} + ${ + apuracao.status === "concluida" + ? "" + : `` + } @@ -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 = ''; + // 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 = + ''; + + // `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 `${PID_DC_ALTERADA_ICONE}`; + } + + // 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 = + ''; + + function pidDcFontePdfAtipicaBadgeHtml(fontePdfAtipica) { + if (!fontePdfAtipica) return ""; + return `${PID_DC_FONTE_ATIPICA_ICONE}`; + } + function renderContas() { const body = document.getElementById("dc-contas-body"); body.innerHTML = ""; @@ -1170,6 +1332,11 @@ document.addEventListener("DOMContentLoaded", async () => { + ${pidDcAlteradaBadgeHtml( + conta.alterada_reprocessamento, + conta.validado, + conta.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(conta.valor_anterior_reprocessamento) : null + )} `; @@ -1326,6 +1493,11 @@ document.addEventListener("DOMContentLoaded", async () => { + ${pidDcAlteradaBadgeHtml( + linha.alterada_reprocessamento, + linha.validado, + linha.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(linha.valor_anterior_reprocessamento) : null + )} `; @@ -1446,7 +1618,13 @@ document.addEventListener("DOMContentLoaded", async () => { return `${label} — Valor${label} — Variação`; }) .join("")} - Observação + + Observação + + + `; } @@ -1508,6 +1686,7 @@ document.addEventListener("DOMContentLoaded", async () => { + ${pidDcAlteradaBadgeHtml(linha.alterada_reprocessamento, linha.validado, pidDcValorAnteriorAvTexto(linha))} `; @@ -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` diff --git a/templates/dashboard-contabil.html b/templates/dashboard-contabil.html index c20604e..42d829a 100644 --- a/templates/dashboard-contabil.html +++ b/templates/dashboard-contabil.html @@ -448,7 +448,7 @@