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 *.log
local_settings.py local_settings.py
# Uploads de teste (base local) — planilhas anexadas só pra processar uma # Uploads de teste (base local) — planilhas/PDFs anexados só pra processar
# importação/apuração, não são conteúdo permanente do app (diferente de # 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). # 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 # Importações/apurações reais são feitas direto na base de produção, nunca
# sincronizadas via git. # sincronizadas via git.
@ -31,6 +31,7 @@ media/planos_saude/operadora/
media/planos_saude/planilha_padrao/ media/planos_saude/planilha_padrao/
media/indicadores/honorarios/ media/indicadores/honorarios/
media/indicadores/tareffa/ media/indicadores/tareffa/
media/contabil/apuracoes/
# Distribuição / empacotamento # Distribuição / empacotamento
build/ 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). 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. 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). 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. **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*`). > 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). **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] linhas_dre: list[LinhaDreExtraida]
meses_analise_vertical: list[str] = field(default_factory=list) meses_analise_vertical: list[str] = field(default_factory=list)
linhas_analise_vertical: list[LinhaAnaliseVerticalExtraida] = 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 @dataclass

View File

@ -18,6 +18,7 @@ uma quebra de campo/palavra de verdade (validado contra
from __future__ import annotations from __future__ import annotations
import re import re
import unicodedata
from datetime import date from datetime import date
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import IO from typing import IO
@ -43,6 +44,27 @@ _GAP_ESPACO = 0.8
_TITULO_DRE = "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO" _TITULO_DRE = "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO"
_TITULO_ANALISE_VERTICAL = "Demonstração Mensal (Análise Vertical)" _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_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_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./-]+)") _RE_CNPJ = re.compile(r"CNPJ:\s*([\d./-]+)")
@ -205,6 +227,11 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
ordem_av = 0 ordem_av = 0
base_x0_dre: float | None = None base_x0_dre: float | None = None
base_x0_av: 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: with pdfplumber.open(origem) as pdf:
if not pdf.pages: if not pdf.pages:
@ -220,8 +247,15 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
texto = linha["texto"].strip() texto = linha["texto"].strip()
if not texto: if not texto:
continue 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": if secao != "analise_vertical":
secao = "analise_vertical" secao = "analise_vertical"
base_x0_av = None 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 — # ou da Análise Vertical, que também reimprime esse título —
# sem essa restrição, `secao` voltaria de "analise_vertical" # sem essa restrição, `secao` voltaria de "analise_vertical"
# pra "dre" a cada quebra de página da Análise 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" secao = "dre"
base_x0_dre = None base_x0_dre = None
continue continue
@ -265,7 +301,7 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
) )
) )
elif secao == "dre": 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 continue
valores = _RE_MONETARIO.findall(texto) valores = _RE_MONETARIO.findall(texto)
if not valores: if not valores:
@ -343,4 +379,5 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
linhas_dre=linhas_dre, linhas_dre=linhas_dre,
meses_analise_vertical=meses_analise_vertical or [], meses_analise_vertical=meses_analise_vertical or [],
linhas_analise_vertical=linhas_analise_vertical, 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 # 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). # 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) 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: class Meta:
verbose_name = "Apuração do Relatório Contábil" 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 # botão ao lado do de observação, pra marcar contas já conferidas durante
# a revisão. # a revisão.
validado = models.BooleanField("Validado pelo contador", default=False) 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: class Meta:
verbose_name = "Conta do Balancete (Relatório Contábil)" 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) oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=True)
# Mesmo espírito de ContabilConta.validado. # Mesmo espírito de ContabilConta.validado.
validado = models.BooleanField("Validado pelo contador", default=False) 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: class Meta:
verbose_name = "Linha da DRE (Relatório Contábil)" 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. # Mesmo espírito de ContabilLinhaDre.oculta_no_relatorio/.validado.
oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=True) oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=True)
validado = models.BooleanField("Validado pelo contador", default=False) 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: class Meta:
verbose_name = "Linha da Análise Vertical (Relatório Contábil)" verbose_name = "Linha da Análise Vertical (Relatório Contábil)"

View File

@ -2249,22 +2249,78 @@ class ContabilContaSerializer(serializers.ModelSerializer):
"observacao", "observacao",
"oculta_no_relatorio", "oculta_no_relatorio",
"validado", "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 ContabilLinhaDreSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ContabilLinhaDre model = ContabilLinhaDre
fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador", "observacao", "oculta_no_relatorio", "validado"] fields = [
read_only_fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador"] "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 ContabilLinhaAnaliseVerticalSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ContabilLinhaAnaliseVertical model = ContabilLinhaAnaliseVertical
fields = ["id", "ordem", "descricao", "nivel", "totalizador", "valores", "observacao", "oculta_no_relatorio", "validado"] fields = [
read_only_fields = ["id", "ordem", "descricao", "nivel", "totalizador", "valores"] "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): class ContabilAchadoSerializer(serializers.ModelSerializer):
@ -2539,6 +2595,7 @@ class ContabilApuracaoListSerializer(serializers.ModelSerializer):
"criado_em", "criado_em",
"concluida_em", "concluida_em",
"total_achados_pendentes", "total_achados_pendentes",
"fonte_pdf_atipica",
] ]
def get_total_achados_pendentes(self, obj: ContabilApuracao) -> int: def get_total_achados_pendentes(self, obj: ContabilApuracao) -> int:
@ -2567,6 +2624,7 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
"criado_em", "criado_em",
"concluida_em", "concluida_em",
"resumo_fechamento", "resumo_fechamento",
"fonte_pdf_atipica",
"contas", "contas",
"linhas_dre", "linhas_dre",
"analise_vertical_meses", "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 exportacao as dashboard_contabil_exportacao
from .dashboard_contabil import formula as dashboard_contabil_formula from .dashboard_contabil import formula as dashboard_contabil_formula
from .dashboard_contabil import indicadores as dashboard_contabil_indicadores 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 .dashboard_contabil import pipeline as dashboard_contabil_pipeline
from .templatetags import contabil_extras as dashboard_contabil_extras from .templatetags import contabil_extras as dashboard_contabil_extras
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
@ -3712,6 +3713,200 @@ def _contabil_arvore_contexto(
return 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): class ContabilApuracaoViewSet(viewsets.ModelViewSet):
"""Dashboard Contábil (Relatórios > Contabilidade) — permissão de toggle """Dashboard Contábil (Relatórios > Contabilidade) — permissão de toggle
único, mesmo espírito de ImportacaoPlanoSaudeViewSet/IndicadorApuracaoViewSet. único, mesmo espírito de ImportacaoPlanoSaudeViewSet/IndicadorApuracaoViewSet.
@ -3787,6 +3982,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
arquivo=ContentFile(conteudo, name=arquivo.name), arquivo=ContentFile(conteudo, name=arquivo.name),
criado_por=request.user, criado_por=request.user,
analise_vertical_meses=resultado.extracao.meses_analise_vertical, analise_vertical_meses=resultado.extracao.meses_analise_vertical,
fonte_pdf_atipica=resultado.extracao.fonte_pdf_atipica,
) )
ContabilConta.objects.bulk_create( ContabilConta.objects.bulk_create(
[ [
@ -3860,12 +4056,119 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
serializer = ContabilApuracaoDetailSerializer(apuracao) serializer = ContabilApuracaoDetailSerializer(apuracao)
return Response(serializer.data, status=status.HTTP_201_CREATED) 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"]) @action(detail=True, methods=["post"])
def concluir(self, request: Request, pk: str | None = None) -> Response: def concluir(self, request: Request, pk: str | None = None) -> Response:
"""Trava a apuração pra edição (observações/achados) — mesmo espírito """Trava a apuração pra edição (observações/achados) — mesmo espírito
de `ImportacaoPlanoSaude.status="concluida"`. Não há "reabrir" nesta de `ImportacaoPlanoSaude.status="concluida"`. Não há "reabrir" nesta
v1 (diferente de planos_saude): reprocessar exige excluir e reanexar v1 (diferente de planos_saude) — `reprocessar()` acima é o jeito de
o PDF.""" corrigir uma apuração ainda "Em revisão" com um PDF novo, sem
perder observações/achados já tratados."""
apuracao = self.get_object() apuracao = self.get_object()
apuracao.status = ContabilApuracao.STATUS_CONCLUIDA apuracao.status = ContabilApuracao.STATUS_CONCLUIDA
apuracao.concluida_em = timezone.now() apuracao.concluida_em = timezone.now()

View File

@ -759,6 +759,45 @@
opacity: 0.5; 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` /* 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 (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/ clique acidental fora de um popup): uma linha extra logo abaixo da conta/
@ -1385,3 +1424,56 @@
color: var(--accent); color: var(--accent);
text-decoration: underline; 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" }); 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 * /dashboard/ é GET (não POST, diferente do padrão /gerar/ de outras
* ferramentas) exatamente pra poder ser aberto assim — navegação de * 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 }); 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) { async function pidAtualizarValidadoContaContabil(id, validado) {
return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { validado } }); return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { validado } });
} }
@ -310,6 +320,22 @@ document.addEventListener("DOMContentLoaded", async () => {
if (contentEl) contentEl.hidden = !canUse; if (contentEl) contentEl.hidden = !canUse;
if (!canUse) return; 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 formView = document.getElementById("dc-form-view");
const reviewView = document.getElementById("dc-review-view"); const reviewView = document.getElementById("dc-review-view");
@ -368,7 +394,7 @@ document.addEventListener("DOMContentLoaded", async () => {
apuracoes.forEach((apuracao) => { apuracoes.forEach((apuracao) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` 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>${pidDcFormatCompetencia(apuracao.competencia)}</td>
<td>${apuracao.total_achados_pendentes}</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> <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>${pidDcFormatData(apuracao.criado_em)}</td>
<td class="pa-row-actions"> <td class="pa-row-actions">
<button type="button" class="btn-outline" data-dc-abrir="${apuracao.id}">Abrir</button> <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"> <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> <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> </button>
@ -397,6 +430,75 @@ document.addEventListener("DOMContentLoaded", async () => {
await pidExcluirApuracaoContabil(Number(excluirBtn.getAttribute("data-dc-excluir"))); await pidExcluirApuracaoContabil(Number(excluirBtn.getAttribute("data-dc-excluir")));
await carregarLista(); 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 ---- // ---- Nova Análise ----
@ -479,7 +581,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const apuracao = apuracaoAtual; const apuracao = apuracaoAtual;
const concluida = apuracao.status === "concluida"; 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 = document.getElementById("dc-review-subtitle").textContent =
`CNPJ ${apuracao.cnpj || "não informado"} · Competência ${pidDcFormatCompetencia(apuracao.competencia)} · ${apuracao.contas.length} conta(s)`; `CNPJ ${apuracao.cnpj || "não informado"} · Competência ${pidDcFormatCompetencia(apuracao.competencia)} · ${apuracao.contas.length} conta(s)`;
document.getElementById("dc-concluida-aviso").hidden = !concluida; document.getElementById("dc-concluida-aviso").hidden = !concluida;
@ -1117,6 +1220,65 @@ document.addEventListener("DOMContentLoaded", async () => {
const PID_DC_VALIDADO_ICONE = 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>'; '<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() { function renderContas() {
const body = document.getElementById("dc-contas-body"); const body = document.getElementById("dc-contas-body");
body.innerHTML = ""; 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"}"> <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} ${PID_DC_OBSERVACAO_ICONE}
</button> </button>
${pidDcAlteradaBadgeHtml(
conta.alterada_reprocessamento,
conta.validado,
conta.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(conta.valor_anterior_reprocessamento) : null
)}
</div> </div>
</td> </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"}"> <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} ${PID_DC_OBSERVACAO_ICONE}
</button> </button>
${pidDcAlteradaBadgeHtml(
linha.alterada_reprocessamento,
linha.validado,
linha.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(linha.valor_anterior_reprocessamento) : null
)}
</div> </div>
</td> </td>
`; `;
@ -1446,7 +1618,13 @@ document.addEventListener("DOMContentLoaded", async () => {
return `<th>${label} — Valor</th><th>${label} — Variação</th>`; return `<th>${label} — Valor</th><th>${label} — Variação</th>`;
}) })
.join("")} .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"}"> <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} ${PID_DC_OBSERVACAO_ICONE}
</button> </button>
${pidDcAlteradaBadgeHtml(linha.alterada_reprocessamento, linha.validado, pidDcValorAnteriorAvTexto(linha))}
</div> </div>
</td> </td>
`; `;
@ -1626,6 +1805,39 @@ document.addEventListener("DOMContentLoaded", async () => {
if (tab === "dashboard") await renderDashboardTab(); 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") ---- // ---- Resumo do Fechamento (texto rico, aba "Dashboard") ----
// Editor contenteditable + colar/arrastar imagem embutida — mesmo padrão // Editor contenteditable + colar/arrastar imagem embutida — mesmo padrão
// de `.ag-richtext` (acessos-gerais.js) e `.ajuda-modal__editor` // 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-tab-panel" data-dc-panel="balancete" hidden>
<div class="pa-table-wrap"> <div class="pa-table-wrap">
<table class="pa-table dc-contas-table"> <table class="pa-table dc-contas-table">
<thead> <thead id="dc-contas-thead">
<tr> <tr>
<th>Conta</th> <th>Conta</th>
<th>Descrição</th> <th>Descrição</th>
@ -456,7 +456,13 @@
<th>Débito</th> <th>Débito</th>
<th>Crédito</th> <th>Crédito</th>
<th>Saldo Atual</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> </tr>
</thead> </thead>
<tbody id="dc-contas-body"></tbody> <tbody id="dc-contas-body"></tbody>
@ -472,11 +478,17 @@
<div class="pa-tab-panel" data-dc-panel="dre" hidden> <div class="pa-tab-panel" data-dc-panel="dre" hidden>
<div class="pa-table-wrap"> <div class="pa-table-wrap">
<table class="pa-table dc-dre-table"> <table class="pa-table dc-dre-table">
<thead> <thead id="dc-dre-thead">
<tr> <tr>
<th>Descrição</th> <th>Descrição</th>
<th>Valor</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> </tr>
</thead> </thead>
<tbody id="dc-dre-body"></tbody> <tbody id="dc-dre-body"></tbody>
@ -492,7 +504,7 @@
<div class="pa-tab-panel" data-dc-panel="analise-vertical" hidden> <div class="pa-tab-panel" data-dc-panel="analise-vertical" hidden>
<div class="pa-table-wrap"> <div class="pa-table-wrap">
<table class="pa-table dc-dre-table dc-av-table"> <table class="pa-table dc-dre-table dc-av-table">
<thead> <thead id="dc-av-thead">
<tr id="dc-av-head-row"> <tr id="dc-av-head-row">
<th>Descrição</th> <th>Descrição</th>
</tr> </tr>
@ -544,6 +556,32 @@
</div> </div>
</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-overlay" id="dc-achado-modal" hidden>
<div class="modal-card"> <div class="modal-card">
<h2 class="modal-card__title" id="dc-achado-modal-titulo"></h2> <h2 class="modal-card__title" id="dc-achado-modal-titulo"></h2>
@ -771,6 +809,10 @@
</div> </div>
</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-overlay" id="calendario-depaula-modal" hidden>
<div class="modal-card iframe-modal-card"> <div class="modal-card iframe-modal-card">
<div class="iframe-modal-header"> <div class="iframe-modal-header">