Melhorias na aplicação Relatório Contábil
This commit is contained in:
parent
b0b63d90a5
commit
9752f7c27d
5
.gitignore
vendored
5
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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()`.
|
||||||
|
|||||||
@ -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).
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
portal_api/migrations/0070_contabil_fonte_pdf_atipica.py
Normal file
18
portal_api/migrations/0070_contabil_fonte_pdf_atipica.py
Normal 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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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)"
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user