Reestruturação das auditorias de balancetes

This commit is contained in:
Gabriel 2026-09-18 10:02:21 -03:00
parent a8db64d949
commit 86c738cc53
11 changed files with 270 additions and 53 deletions

View File

@ -86,7 +86,9 @@
"Bash(node -e \"require\\('vm'\\).Script\\(require\\('fs'\\).readFileSync\\('static/js/dashboard-contabil.js','utf8'\\)\\)\")",
"Bash(.venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/sim_destaque.py\")",
"Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/sim_destaque.py\")",
"Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/sim_expandir_tudo.py\")"
"Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/sim_expandir_tudo.py\")",
"Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/test_reprocessamento_log.py\")",
"Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/test_recria_achados.py\")"
]
}
}

View File

@ -8,6 +8,7 @@ from .models import (
CompromissoAgenda,
ContabilAchado,
ContabilApuracao,
ContabilApuracaoReprocessamento,
ContabilConta,
ContabilLinhaAnaliseVertical,
ContabilLinhaDre,
@ -256,6 +257,12 @@ class ContabilApuracaoAdmin(admin.ModelAdmin):
ordering = ("-competencia",)
@admin.register(ContabilApuracaoReprocessamento)
class ContabilApuracaoReprocessamentoAdmin(admin.ModelAdmin):
list_display = ("apuracao", "reprocessado_por", "reprocessado_em")
ordering = ("-reprocessado_em",)
@admin.register(ContabilConta)
class ContabilContaAdmin(admin.ModelAdmin):
list_display = ("apuracao", "codigo", "descricao", "tipo", "saldo_atual")

View File

@ -341,3 +341,19 @@ Pedido explícito do usuário: um botão pequeno no canto esquerdo do cabeçalho
`.dc-expandir-tudo-btn` (`data-dc-expandir-tudo`) nas 3 tabelas da revisão (Balancete, D.R.E. e Análise Vertical — cada uma com o seu, já que cada árvore tem estado de colapso próprio, mesmo motivo do botão "Restaurar formatação" que já existia), dentro do novo modificador `.dc-th-linha--inicio` (só troca o `justify-content` pra `flex-start`, já que aqui o botão vem antes do rótulo, e não colado na borda direita como o de restaurar). Não é um toggle: o caminho de volta é o próprio "Restaurar formatação padrão", na última coluna da mesma tabela.
Detalhe não-óbvio da implementação: expandir tudo **zera** o conjunto de grupos expandidos (`dc*Expandidos`) em vez de populá-lo com todos os grupos — com ele vazio, o destaque volta a sair de `dcUltimaLevaVisivel()`, que sem nada recolhido marca exatamente as folhas (as contas analíticas finais). Conferido contra a apuração real `2021`/`08-2026`: 133 contas visíveis e 74 destacadas, exatamente as 74 contas sem filhos. Popular o conjunto com todos os grupos destacaria quase toda linha da tabela, recaindo no mesmo "tudo destacado, nada se destaca" da rodada 140. Só frontend (HTML/CSS/JS), sem mudança de model/endpoint; o relatório "Gerar Dashboard" não ganhou esse botão nesta rodada.
### 142. Log de reprocessamentos — quem reprocessou e quantas vezes
Pedido explícito do usuário: "quando o usuário reprocessar a análise, o ideal é que registre um log... pra que seja possível identificar quem reprocessou e quantos reprocessamentos já ocorreu."
Model novo `ContabilApuracaoReprocessamento` (`reprocessado_por` FK `SET_NULL` + `reprocessado_em`, migração `0075`) — mesmo espírito de `ContabilObservacaoEdicao`, um registro por reprocessamento bem-sucedido. `ContabilApuracaoViewSet.reprocessar()` grava o registro **dentro** da mesma transação da resincronização (logo depois de sincronizar os achados), então uma tentativa que falhar no meio do caminho não deixa registro órfão. `ContabilApuracaoListSerializer` (a lista de execuções, de onde o botão "Reprocessar" é aberto) ganhou `reprocessamentos` (nested, mais recente primeiro), com `prefetch_related` novo só pra essa action, evitando N+1.
Frontend: o modal "Reprocessar análise" ganhou uma seção (nasce escondida numa apuração nunca reprocessada) com a contagem e a lista "Nome · dd/mm/aaaa hh:mm" — sem nenhuma chamada de API própria, já que os dados vêm da lista que a tela já carregou. Novo formatador `pidDcFormatDataHora()` (os demais formatadores de data desta tela só mostram o dia, sem hora — aqui a hora importa porque mais de um reprocessamento pode acontecer no mesmo dia). Validado com um script Python isolado (transação com rollback forçado, contra uma apuração real) confirmando ordem/serialização; nada persistido em produção além da migração. Detalhe técnico completo no `CLAUDE.md` desta pasta.
### 143. Achados voltam a ser recriados do zero a cada reprocessamento (revisão da decisão da rodada 124)
Pedido explícito do usuário: "sempre que o usuário reprocessar um documento, os apontamentos que são realizados pela própria aplicação e constam na tela de Observações devem ser refeitos. Isso já ressalta para o usuário que não há mais inconsistências para validar e pode verificar apenas se surgiram novos. As observações e comentários que ele realizou nas contas devem permanecer independente do reprocessamento." Reverte por completo a decisão da rodada 124 — até aqui, um achado (apontamento automático de auditoria) já tratado pelo contador continuava marcado como "tratado" mesmo depois de a inconsistência sumir dos dados do PDF novo, contrariando o próprio propósito da tela (sinalizar o que ainda precisa de atenção).
`_contabil_sincroniza_achados()` (que casava por `(regra, código da conta)` e preservava `status`/`observacao_contador`/`tratado_por`/`tratado_em`/`oculto_no_relatorio`) virou `_contabil_recria_achados()` — apaga **todos** os achados da apuração (`apuracao.achados.all().delete()`) e recria do zero a partir do motor de regras rodado sobre o PDF novo (mesmo `bulk_create()` de `create()`). Todo achado nasce `pendente`, mesmo que a mesma combinação regra+conta já tivesse sido tratada com justificativa antes do reprocessamento — os valores mudaram, então a tratativa antiga deixa de fazer sentido. A preocupação original da rodada 124 (delete+recria quebraria a FK `conta` de um achado preservado) não se aplica mais: não existe mais achado "preservado" através do delete, a FK é sempre resolvida fresca contra as contas já sincronizadas na mesma transação.
**`ContabilObservacao` (comentário/observação do contador numa conta, sistema separado desde a rodada 126) não é tocada** — nunca teve relação com achados, é casada por chave natural (empresa+conta) e sobrevive a qualquer reprocessamento, exatamente como pedido. Validado com um script Python isolado (`transaction.atomic()` com rollback forçado, contra uma apuração real já em produção): um achado marcado manualmente como "tratado" com justificativa desapareceu depois de simular `_contabil_recria_achados()` com a mesma lista de achados detectados, dando lugar a um achado novo `pendente` com o mesmo conteúdo (regra/conta/mensagem) mas `id` diferente; uma `ContabilObservacao` criada na mesma apuração permaneceu intacta. Nada persistido em produção. Detalhe técnico completo no `CLAUDE.md` desta pasta.

View File

@ -49,7 +49,7 @@ Padrão cabeçalho → linhas de detalhe → achados (mesma filosofia de `Indica
- **`ContabilConta`**: uma linha do Balancete. `observacao` (`TextField`, editável via PATCH em **qualquer** conta, tenha ela gerado achado ou não) — é o espaço de "análise" pedido pelo usuário, independente da auditoria automática. `oculta_no_relatorio` (default `True`, invertido numa rodada — ver "Editor de observação inline" abaixo) e `validado` (`BooleanField`, default `False` — checkbox informativo de "já conferi esta conta", sem efeito em achado/observação/relatório).
- **`ContabilLinhaDre`**: uma linha da DRE, sem código de classificação (o relatório não traz um pra DRE, diferente do Balancete). Mesmos `oculta_no_relatorio`/`validado` de `ContabilConta`.
- **`ContabilLinhaAnaliseVertical`** (rodada 123): uma linha da Demonstração Mensal (Análise Vertical) — mesma árvore/descrição/nível da DRE, mas `valores` (`JSONField`) guarda um `{"valor": "...", "percentual": "..."}` por mês em vez de um `DecimalField` único (gravado como texto, não float, pra não perder precisão), alinhado por posição com `ContabilApuracao.analise_vertical_meses` (ex.: `["mai/2026", "jun/2026", "jul/2026"]`, lista compartilhada por toda a apuração, não por linha). Mesmos `observacao`/`oculta_no_relatorio`/`validado` de `ContabilConta`/`ContabilLinhaDre` — recursos por linha idênticos (editor inline, tri-state, ocultar do relatório), decisão confirmada com o usuário via `AskUserQuestion` antes de implementar. Lista vazia (`analise_vertical_meses=[]`, nenhuma linha) quando o PDF não tinha essa seção — relatório antigo, ou empresa sem essa seção habilitada no Questor; a aba/tab correspondente some nesse caso (ver "Análise Vertical" abaixo).
- **`ContabilAchado`**: achado de auditoria, nasce automático em `create()`, nunca é apagado — só muda de `status` (`pendente`/`tratado`/`ignorado`), sempre com `observacao_contador` obrigatória ao mudar de pendente (mesmo espírito de "histórico completo preservado" de `ImportacaoPlanoSaudeAuditoria`). `conta` é nullable — achados 1 e 2 (balanceamento/débito-crédito) são gerais, sem uma conta específica.
- **`ContabilAchado`**: achado de auditoria, nasce automático em `create()`, só muda de `status` (`pendente`/`tratado`/`ignorado`) via `ContabilAchadoViewSet`, sempre com `observacao_contador` obrigatória ao mudar de pendente. **Exceção**: um reprocessamento apaga e recria **todos** os achados da apuração do zero (`_contabil_recria_achados()`, rodada 143 — decisão revisada, ver "Reprocessar" abaixo), então "nunca é apagado" só vale fora desse fluxo. `conta` é nullable — achados 1 e 2 (balanceamento/débito-crédito) são gerais, sem uma conta específica.
## `ContabilApuracaoViewSet.create()` — ordem de operações não-trivial
@ -367,16 +367,16 @@ Dois filtros de template novos em `contabil_extras.py` — `moeda_av`/`percentua
### 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).
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 nesta rodada: 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). **Decisão sobre achados revertida na rodada 143** (ver "Achados são recriados do zero a cada reprocessamento" mais abaixo) — o comportamento atual é o oposto do descrito aqui: achado é apagado e recriado do zero a cada reprocessamento, só a parte de conta/linha (parágrafo acima) continua como decidido nesta rodada.
**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:
**`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 — cada uma com uma estratégia diferente, conforme o que precisa (ou não) persistir através de um reprocessamento:
- `_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"`.
- `_contabil_sincroniza_contas()`/`_contabil_sincroniza_linhas_dre()`/`_contabil_sincroniza_linhas_analise_vertical()` — **atualização no lugar (mesmo `id`), não delete+recria**: 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); 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()`. Mantidas assim (não delete+recria) porque `codigo`/`(descricao, nivel)` **é** a identidade da conta/linha do ponto de vista do contador — o mesmo id continua referenciado por `ContabilObservacao` (chave natural, mas ainda assim o `id` da conta importa pra `_contabil_arvore_contexto()` no relatório) e é preciso preservar `validado`/observações de itens que não mudaram.
- `_contabil_recria_achados()` (**rodada 143, revisão de uma decisão anterior** — ver abaixo) — **delete+recria total**, mesmo `bulk_create` de `create()`: apaga **todos** os achados da apuração e gera um conjunto novo a partir das regras rodadas sobre o PDF novo. Todo achado nasce `pendente`, mesmo que a mesma `(regra, conta)` já tivesse sido tratada antes do reprocessamento.
**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.
**Por que os dois grupos usam estratégias opostas**: conta/linha é dado extraído do PDF que o contador **anota** (observação/validado) — o valor de hoje precisa ser atualizado, mas a anotação de ontem sobre a mesma conta continua valendo, então "atualiza no lugar" preserva tudo que não mudou. Achado é um **apontamento derivado**, recalculado inteiramente a cada rodada das regras — não existe "achado que não mudou", ele ou dispara com os dados de agora ou não dispara; manter um achado "tratado" que não dispara mais equivalia a mostrar uma inconsistência que já não existe.
**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**: 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).
@ -386,6 +386,20 @@ Pedido explícito do usuário: até aqui, corrigir uma apuração com o PDF erra
**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.
**Log de reprocessamentos (rodada 142)**: pedido explícito do usuário — "identificar quem reprocessou e quantos reprocessamentos já ocorreu". Model novo `ContabilApuracaoReprocessamento` (`related_name="reprocessamentos"`, `reprocessado_por` FK `SET_NULL`, `reprocessado_em` `auto_now_add`, migração `0075`) — mesmo espírito de `ContabilObservacaoEdicao`, um registro por chamada bem-sucedida. `reprocessar()` cria o registro **dentro** do `with transaction.atomic()`, logo depois de `_contabil_recria_achados()` — uma tentativa que falhar no meio do caminho (PDF de empresa errada, erro de sincronização) não deixa um registro órfão, já que a transação inteira reverte junto.
**Achados são recriados do zero a cada reprocessamento (rodada 143, revisão da decisão da rodada 124)**: pedido explícito do usuário — "os apontamentos que são realizados pela própria aplicação e constam na tela de Observações devem ser refeitos [a cada reprocessamento]. Isso já ressalta para o usuário que não há mais inconsistências para validar e pode verificar apenas se surgiram novos. As observações e comentários que ele realizou nas contas devem permanecer independente do reprocessamento." Reverte por completo a decisão da rodada 124 de nunca apagar/sobrescrever `status`/`observacao_contador` de achado — o comportamento anterior deixava um apontamento "tratado" pendurado na aba Observações mesmo depois de a inconsistência sumir dos dados novos, contrariando o próprio propósito de sinalizar o que ainda precisa de atenção.
`_contabil_sincroniza_achados()` virou `_contabil_recria_achados()` (`views.py`) — em vez de casar por `(regra, código da conta)` e atualizar/preservar campo a campo, agora é `apuracao.achados.all().delete()` seguido de `ContabilAchado.objects.bulk_create(...)` com a lista fresca de `resultado.achados`, **exatamente o mesmo bloco de `create()`** (só que sobre uma apuração já existente). Todo achado nasce `pendente`, mesmo que a mesma combinação `(regra, conta)` já estivesse `tratado`/`ignorado` com uma justificativa escrita antes — a justificativa antiga não é preservada em lugar nenhum, some junto com o achado antigo (decisão explícita: os valores mudaram, então a tratativa escrita sobre os valores antigos não é mais válida).
**Por que isso não quebra a FK de conta/observação**: a preocupação original da rodada 124 (delete+recria quebraria a FK `ContabilAchado.conta` de um achado preservado) não se aplica mais porque não há mais achado "preservado" através do delete — todo achado é recriado do zero, então a FK é sempre resolvida fresca contra `contas_por_codigo` (o mapa de contas **já sincronizadas** por `_contabil_sincroniza_contas()`, que roda antes na mesma transação). `ContabilObservacao` (comentário do contador na conta, pedido explícito do usuário pra continuar intocado) nunca teve relação nenhuma com `ContabilAchado` — é casada por chave natural (empresa+conta), vive num model totalmente separado, e `_contabil_recria_achados()` nem toca nela.
Validado com um script Python isolado (`transaction.atomic()` com rollback forçado, contra uma apuração real já em produção): um achado marcado manualmente como `tratado` com justificativa, seguido de uma chamada simulando `_contabil_recria_achados()` com a mesma lista de achados detectados — o achado tratado desaparece e um novo `pendente` idêntico em conteúdo (mesma regra/conta/mensagem) toma o lugar, com `id` diferente; nada persistido além da migração já aplicada em rodadas anteriores.
`ContabilApuracaoListSerializer` ganhou `reprocessamentos` (nested, via `ContabilApuracaoReprocessamentoSerializer` — só `id`/`reprocessado_por_nome`/`reprocessado_em`) — não um campo `Detail`, porque o único botão "Reprocessar" da tela vive na **lista** (`data-dc-reprocessar-abrir`, ao lado de "Abrir"), nunca dentro da revisão já aberta; `get_queryset()` ganhou `prefetch_related("reprocessamentos__reprocessado_por")` só pra `action == "list"`, evitando N+1 por apuração (mesmo cuidado que `total_achados_pendentes` já deveria ter tido, mas não tinha — não corrigido aqui, fora do escopo do pedido).
**Frontend**: `abrirReprocessarModal(id)` ganhou `renderReprocessarLog(id)`, que procura a apuração em `dcListaApuracoes` (já carregada, sem chamada de API própria) e preenche uma seção nova dentro do próprio `#dc-reprocessar-modal` (`#dc-reprocessar-log`, nasce `hidden` — não aparece nada numa apuração nunca reprocessada) com a contagem (`.dc-reprocessar-log__count`, mesmo padrão visual de `.dc-obs-resumo__count`) e a lista "Nome · dd/mm/aaaa hh:mm" (mais recente primeiro, ordem que já vem do `Meta.ordering` do model). `pidDcFormatDataHora()` (novo, ao lado de `pidDcFormatData()`) é o primeiro formatador de data+hora deste arquivo — os demais (assinatura de observação, "Criado em" da lista) só mostram a data, sem hora; aqui a hora importa porque mais de um reprocessamento pode acontecer no mesmo dia. Como `carregarLista()` já roda depois de um reprocessamento bem-sucedido (pra atualizar a linha da tabela), o log fica correto da próxima vez que o modal for aberto pra mesma apuração, sem nenhuma chamada extra. Validado com um script Python ad-hoc (dentro de `transaction.atomic()` com rollback forçado, contra uma apuração real já em produção): dois registros criados, serializados na ordem certa (mais recente primeiro) e com `reprocessado_por_nome` nulo tratado como esperado; nada persistido.
### 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.
@ -406,7 +420,7 @@ Pedido explícito do usuário: uma observação registrada num mês (o exemplo d
Três decisões de escopo confirmadas por `AskUserQuestion` **antes** de implementar, todas com a opção recomendada aceita:
1. **Toda observação propaga por padrão** — não existe "fixar"; o que existe é o inverso, encerrar explicitamente. Evita histórico que só existe quando alguém lembra de marcar.
2. **O histórico cobre Balancete/D.R.E./Análise Vertical** — a justificativa de tratativa de um item de auditoria (`ContabilAchado.observacao_contador`) continua presa à apuração como sempre foi (ela já tem histórico próprio, nunca é apagada nem sobrescrita, ver "Models" acima). Misturar os dois fluxos aumentaria o escopo sem ganho claro.
2. **O histórico cobre Balancete/D.R.E./Análise Vertical** — a justificativa de tratativa de um item de auditoria (`ContabilAchado.observacao_contador`) continua presa à apuração como sempre foi. Misturar os dois fluxos aumentaria o escopo sem ganho claro. (Nota da rodada 143: essa justificativa **não** sobrevive mais a um reprocessamento — o achado inteiro é recriado do zero nesse fluxo, ver "Achados são recriados do zero a cada reprocessamento" mais abaixo; ela só é permanente enquanto a apuração não é reprocessada, diferente de `ContabilObservacao`, que atravessa competências inteiras.)
3. **`mostrar_ao_cliente` é sempre alternável**, inclusive numa observação já travada — o bloqueio protege texto, autor e data; mostrar ou não ao cliente é decisão editorial de cada relatório, e uma marcação errada precisa ser corrigível sem reescrever o histórico.
**Model `ContabilObservacao`** (`portal_api/models.py`, migração `0071`): escopo `codigo_empresa` + `alvo_tipo` (`conta`/`dre`/`analise_vertical`) + `alvo_chave`, mais `alvo_rotulo` (descrição no momento em que foi escrita, só pra exibir se aquela conta sumir do plano), `apuracao_origem` (`SET_NULL`) + `competencia_origem` (cópia, pra vigência continuar resolvendo se a apuração for excluída), `texto`, `mostrar_ao_cliente` (substitui `oculta_no_relatorio`, com o sinal invertido pra bater com o rótulo que o contador vê), `criado_por`/`criado_em` e o trio `encerrada_em_competencia`/`encerrada_por`/`encerrada_em`.
@ -471,6 +485,6 @@ Corrigido com um helper novo, `_moeda(valor: Decimal) -> str` (`regras.py`), dup
**Não corrigido nesta rodada** (fora do escopo do pedido, mesma família de bug): `regra_variacao_atipica_dre` formata percentual com `f"{valor:.2f}%"` (ponto decimal, ex. "12.34%"), também inconsistente com o padrão BR (`12,34%`) — se o usuário pedir, é o mesmo tipo de ajuste.
**Só vale pra achados gerados dali em diante** — um achado já persistido em produção mantém o texto antigo (sem formatação) até a apuração ser reprocessada (`reprocessar()`/`_contabil_sincroniza_achados()` em `views.py` sobrescreve `mensagem` de um achado que ainda dispara na mesma conta+regra) ou até uma nova apuração da mesma empresa ser criada do zero; não foi escrita nenhuma migração de dados pra reformatar o texto já gravado (parsear números dentro de frase livre por regex é arriscado — o mesmo texto tem números que não são valores, como código de conta "1.01.01.001").
**Só vale pra achados gerados dali em diante** — um achado já persistido em produção mantém o texto antigo (sem formatação) até a apuração ser reprocessada (`reprocessar()`/`_contabil_recria_achados()` em `views.py` recria **todo** achado do zero, inclusive `mensagem`, desde a rodada 143 — ver "Achados são recriados do zero a cada reprocessamento" abaixo) ou até uma nova apuração da mesma empresa ser criada do zero; não foi escrita nenhuma migração de dados pra reformatar o texto já gravado (parsear números dentro de frase livre por regex é arriscado — o mesmo texto tem números que não são valores, como código de conta "1.01.01.001").
**Testado ponta a ponta via `Client.force_login()`** dentro de uma transação com rollback forçado: listagem por vigência, criação com chave derivada no servidor, alvo de outra apuração recusado, edição de texto, bloqueio do texto quando a apuração de origem está concluída (com a visibilidade ainda alternável nesse mesmo caso), encerrar e reativar com as fronteiras de competência conferidas nos dois sentidos, herança numa competência seguinte (observação marcada como histórica) e o relatório gerado nos dois meses, incluindo a conferência de que observação interna não vaza pro relatório do cliente. Nada gravado em produção além da própria migração.

View File

@ -0,0 +1,29 @@
# Generated by Django 6.0.7 on 2026-09-18 12:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0074_importacaoplanosaudedepaulalinha_tipo_pessoa_and_more'),
]
operations = [
migrations.CreateModel(
name='ContabilApuracaoReprocessamento',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reprocessado_em', models.DateTimeField(auto_now_add=True, verbose_name='Reprocessado em')),
('apuracao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reprocessamentos', to='portal_api.contabilapuracao')),
('reprocessado_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reprocessamentos_contabeis', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Reprocessamento do Relatório Contábil',
'verbose_name_plural': 'Reprocessamentos do Relatório Contábil',
'ordering': ['-reprocessado_em', '-id'],
},
),
]

View File

@ -2065,6 +2065,29 @@ class ContabilApuracao(models.Model):
return f"{self.codigo_empresa} — {self.competencia:%m/%Y}"
class ContabilApuracaoReprocessamento(models.Model):
"""Log de cada reprocessamento de uma `ContabilApuracao` (pedido explícito
do usuário — "identificar quem reprocessou e quantos reprocessamentos já
ocorreu"), mesmo espírito de `ContabilObservacaoEdicao`. Um registro por
chamada bem-sucedida de `ContabilApuracaoViewSet.reprocessar()` — criado
dentro da mesma transação da resincronização, então uma tentativa que
falhar no meio do caminho não deixa um registro órfão aqui."""
apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="reprocessamentos")
reprocessado_por = models.ForeignKey(
Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="reprocessamentos_contabeis"
)
reprocessado_em = models.DateTimeField("Reprocessado em", auto_now_add=True)
class Meta:
verbose_name = "Reprocessamento do Relatório Contábil"
verbose_name_plural = "Reprocessamentos do Relatório Contábil"
ordering = ["-reprocessado_em", "-id"]
def __str__(self) -> str:
return f"Reprocessamento de {self.apuracao_id} em {self.reprocessado_em:%d/%m/%Y %H:%M}"
class ContabilConta(models.Model):
"""Uma linha do Balancete extraída do PDF (grupo Ativo/Passivo, sintética
ou analítica). `observacao` é livre e editável pelo contador via PATCH em
@ -2199,11 +2222,19 @@ class ContabilLinhaAnaliseVertical(models.Model):
class ContabilAchado(models.Model):
"""Um achado gerado automaticamente pelo motor de regras
(`dashboard_contabil.regras`) ao criar a apuração — nunca é apagado, só
muda de `status` conforme o contador revisa (mesmo espírito de
`ImportacaoPlanoSaudeAuditoria`: histórico completo preservado). `conta`
é opcional porque alguns achados são gerais (ex.: desbalanceamento
Ativo x Passivo), sem uma única conta associada."""
(`dashboard_contabil.regras`) ao criar a apuração, só muda de `status`
conforme o contador revisa (mesmo espírito de `ImportacaoPlanoSaudeAuditoria`).
`conta` é opcional porque alguns achados são gerais (ex.: desbalanceamento
Ativo x Passivo), sem uma única conta associada.
**Exceção a "nunca é apagado"**: um reprocessamento (`ContabilApuracaoViewSet.
reprocessar()`/`_contabil_recria_achados()` em views.py) apaga **todos**
os achados da apuração e recria do zero a partir das regras rodadas sobre
o PDF novo — decisão explícita do usuário, pra um apontamento que não
dispara mais sumir da aba Observações em vez de ficar pendurado "tratado"
(ver CHANGELOG.md do pacote `dashboard_contabil`). Fora desse fluxo (ex.:
`ContabilAchadoViewSet.update()`/`alternar_oculto()`), o registro nunca é
apagado, só muda de campo."""
SEVERIDADE_ALTA = "alta"
SEVERIDADE_MEDIA = "media"

View File

@ -18,6 +18,7 @@ from .models import (
CompromissoAgenda,
ContabilAchado,
ContabilApuracao,
ContabilApuracaoReprocessamento,
ContabilConta,
ContabilLinhaAnaliseVertical,
ContabilLinhaDre,
@ -2680,9 +2681,24 @@ class IndicadorContabilDefinicaoInputSerializer(serializers.Serializer):
return attrs
class ContabilApuracaoReprocessamentoSerializer(serializers.ModelSerializer):
"""Um item do log de reprocessamentos de uma apuração (ver
`ContabilApuracaoReprocessamento`) — alimenta o modal "Reprocessar
análise" da lista, pra o contador ver quem já reprocessou e quantas
vezes, antes de decidir se reprocessa de novo."""
reprocessado_por_nome = serializers.CharField(source="reprocessado_por.nome", read_only=True, default=None)
class Meta:
model = ContabilApuracaoReprocessamento
fields = ["id", "reprocessado_por_nome", "reprocessado_em"]
read_only_fields = fields
class ContabilApuracaoListSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
total_achados_pendentes = serializers.SerializerMethodField()
reprocessamentos = ContabilApuracaoReprocessamentoSerializer(many=True, read_only=True)
class Meta:
model = ContabilApuracao
@ -2698,6 +2714,7 @@ class ContabilApuracaoListSerializer(serializers.ModelSerializer):
"concluida_em",
"total_achados_pendentes",
"fonte_pdf_atipica",
"reprocessamentos",
]
def get_total_achados_pendentes(self, obj: ContabilApuracao) -> int:

View File

@ -56,6 +56,7 @@ from .models import (
CompromissoAgenda,
ContabilAchado,
ContabilApuracao,
ContabilApuracaoReprocessamento,
ContabilConta,
ContabilLinhaAnaliseVertical,
ContabilLinhaDre,
@ -3990,46 +3991,44 @@ def _contabil_sincroniza_linhas_analise_vertical(
antiga.delete()
def _contabil_sincroniza_achados(
def _contabil_recria_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()
}
"""Recria do zero todo `ContabilAchado` de um reprocessamento — apaga
**todos** os achados existentes da apuração e recria a partir do motor de
regras rodado sobre o PDF novo, mesmo `bulk_create` de `create()` (ver
acima). Decisão revisada explicitamente pelo usuário (substitui a
decisão anterior de preservar `status`/`observacao_contador`/`tratado_por`/
`tratado_em`/`oculto_no_relatorio` — ver histórico no CHANGELOG.md do
pacote): um apontamento automático que não dispara mais com os dados
novos precisa **sumir** da aba Observações, sinalizando ao contador que
aquela inconsistência não existe mais, em vez de continuar pendurado
(mesmo "tratado") como se ainda precisasse de atenção. Todo achado que
ainda dispara nasce de novo como `pendente`, mesmo que já tivesse sido
tratado antes do reprocessamento — o contador revisa de novo com os
valores atuais, não reaproveita uma justificativa escrita sobre dados que
já mudaram.
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(
**Isolado do resto do reprocessamento**: observações do contador
(`ContabilObservacao`, ver o model) não vivem aqui, são casadas por chave
natural (empresa+conta) e continuam intactas através de qualquer
reprocessamento — só o apontamento automático é recriado."""
contas_por_codigo = {conta.codigo: conta for conta in apuracao.contas.all()}
apuracao.achados.all().delete()
ContabilAchado.objects.bulk_create(
[
ContabilAchado(
apuracao=apuracao,
conta=conta,
conta=contas_por_codigo.get(achado.codigo_conta) if achado.codigo_conta else None,
regra=achado.regra,
severidade=achado.severidade,
titulo=achado.titulo,
mensagem=achado.mensagem,
valor_referencia=achado.valor_referencia,
)
for achado in achados_detectados
]
)
class ContabilApuracaoViewSet(viewsets.ModelViewSet):
@ -4054,6 +4053,8 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
queryset = queryset.prefetch_related(
"contas", "linhas_dre", "linhas_analise_vertical", "achados__conta"
)
elif self.action == "list":
queryset = queryset.prefetch_related("reprocessamentos__reprocessado_por")
return queryset
def get_serializer_class(self) -> type[ModelSerializer]:
@ -4186,8 +4187,8 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
"""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:
zero — pedido explícito do usuário, pra preservar observações do
contador:
- Conta/linha já existente (casada por `codigo`, no Balancete, ou por
`(descricao, nivel)`, na DRE/Análise Vertical — mesma convenção já
@ -4202,12 +4203,19 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
(`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.
- **Todo achado (apontamento automático de auditoria) é recriado do
zero** — `_contabil_recria_achados()` apaga os achados existentes e
gera um conjunto novo a partir das regras rodadas sobre o PDF novo,
todos nascendo `pendente` (decisão revisada explicitamente pelo
usuário: um apontamento que não dispara mais deve sumir da aba
Observações, não continuar pendurado "tratado" — ver
CHANGELOG.md do pacote pra decisão anterior que esta substitui).
Observação/comentário do contador nas contas (`ContabilObservacao`)
**não** é achado, continua intocada.
- Cada reprocessamento bem-sucedido grava um `ContabilApuracaoReprocessamento`
(quem + quando), pra o contador ver o histórico completo e a
contagem antes de decidir se reprocessa de novo (modal "Reprocessar
análise" da lista) — pedido explícito do usuário.
Bloqueado numa apuração "Concluída" (`_contabil_garante_em_revisao`,
mesmo gate de qualquer edição) e numa competência/empresa diferente
@ -4267,7 +4275,8 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
_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)
_contabil_recria_achados(apuracao, resultado.achados)
ContabilApuracaoReprocessamento.objects.create(apuracao=apuracao, reprocessado_por=request.user)
except Exception:
if novo_arquivo_salvo:
apuracao.arquivo.delete(save=False)

View File

@ -251,6 +251,60 @@
flex-shrink: 0;
}
/* Log de reprocessamentos anteriores, dentro do modal "Reprocessar análise"
— reaproveita .dc-obs-resumo__titulo/__count (mesmo "título + contagem em
pílula" já usado no resumo de observações por aba), só a lista em si é
nova. Nasce `hidden` (ver dashboard-contabil.js) quando a apuração ainda
não foi reprocessada nenhuma vez — sem seção vazia sobrando no modal. */
.dc-reprocessar-log {
margin-top: var(--space-1);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
}
.dc-reprocessar-log__titulo {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.95rem;
margin: 0 0 var(--space-2);
}
.dc-reprocessar-log__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 var(--space-2);
border-radius: var(--radius-pill);
background: rgba(var(--accent-rgb), 0.14);
color: var(--accent);
font-size: 0.75rem;
font-weight: 700;
}
.dc-reprocessar-log__lista {
list-style: none;
margin: 0;
padding: 0;
max-height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.dc-reprocessar-log__item {
font-size: 0.82rem;
color: var(--text-muted);
}
.dc-reprocessar-log__item strong {
color: var(--text-primary);
font-weight: 600;
}
/* Achados */
/* Resumo do topo da aba Observações — donut por severidade (SVG puro, sem

View File

@ -145,6 +145,17 @@ function pidDcFormatData(iso) {
return new Date(iso).toLocaleDateString("pt-BR");
}
function pidDcFormatDataHora(iso) {
if (!iso) return "";
return new Date(iso).toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function pidDcEscapeHtml(texto) {
const div = document.createElement("div");
div.textContent = texto == null ? "" : String(texto);
@ -760,11 +771,34 @@ document.addEventListener("DOMContentLoaded", async () => {
const resetReprocessarArquivoField = wireArquivoField("dc-reprocessar-arquivo");
let dcReprocessarApuracaoId = null;
// Log de reprocessamentos anteriores (quem + quando) — pedido explícito do
// usuário, "identificar quem reprocessou e quantos reprocessamentos já
// ocorreu". `dcListaApuracoes` já carrega `reprocessamentos` (ordenado do
// mais recente pro mais antigo, ver Meta.ordering do model) junto do resto
// da lista, então não precisa de uma chamada de API própria — só procurar
// a apuração pelo id.
function renderReprocessarLog(id) {
const secao = document.getElementById("dc-reprocessar-log");
const apuracao = dcListaApuracoes.find((a) => a.id === id);
const reprocessamentos = apuracao ? apuracao.reprocessamentos || [] : [];
secao.hidden = reprocessamentos.length === 0;
if (reprocessamentos.length === 0) return;
document.getElementById("dc-reprocessar-log-count").textContent = reprocessamentos.length;
document.getElementById("dc-reprocessar-log-lista").innerHTML = reprocessamentos
.map(
(r) =>
`<li class="dc-reprocessar-log__item"><strong>${pidDcEscapeHtml(r.reprocessado_por_nome || "Usuário removido")}</strong> · ${pidDcFormatDataHora(r.reprocessado_em)}</li>`
)
.join("");
}
function abrirReprocessarModal(id) {
dcReprocessarApuracaoId = id;
document.getElementById("dc-reprocessar-arquivo").value = "";
resetReprocessarArquivoField();
document.getElementById("dc-reprocessar-error").textContent = "";
renderReprocessarLog(id);
document.getElementById("dc-reprocessar-modal").hidden = false;
}

View File

@ -619,6 +619,10 @@
</button>
</div>
</div>
<section class="dc-reprocessar-log" id="dc-reprocessar-log" hidden>
<h3 class="dc-reprocessar-log__titulo">Reprocessamentos anteriores <span class="dc-reprocessar-log__count" id="dc-reprocessar-log-count">0</span></h3>
<ul class="dc-reprocessar-log__lista" id="dc-reprocessar-log-lista"></ul>
</section>
<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>