Inclusão da Análise Vertical

This commit is contained in:
Gabriel 2026-09-11 09:36:56 -03:00
parent 617d3afa32
commit b0b63d90a5
16 changed files with 784 additions and 16 deletions

View File

@ -9,6 +9,7 @@ from .models import (
ContabilAchado, ContabilAchado,
ContabilApuracao, ContabilApuracao,
ContabilConta, ContabilConta,
ContabilLinhaAnaliseVertical,
ContabilLinhaDre, ContabilLinhaDre,
Departamento, Departamento,
EmpresaQuestor, EmpresaQuestor,
@ -265,6 +266,12 @@ class ContabilLinhaDreAdmin(admin.ModelAdmin):
search_fields = ("descricao",) search_fields = ("descricao",)
@admin.register(ContabilLinhaAnaliseVertical)
class ContabilLinhaAnaliseVerticalAdmin(admin.ModelAdmin):
list_display = ("apuracao", "ordem", "descricao", "totalizador")
search_fields = ("descricao",)
@admin.register(ContabilAchado) @admin.register(ContabilAchado)
class ContabilAchadoAdmin(admin.ModelAdmin): class ContabilAchadoAdmin(admin.ModelAdmin):
list_display = ("apuracao", "regra", "severidade", "status", "titulo") list_display = ("apuracao", "regra", "severidade", "status", "titulo")

View File

@ -208,3 +208,9 @@ Pedido separado na mesma rodada: o relatório "Gerar Dashboard" ganhou o mesmo
### 122. Botão "validado" de uma conta/linha sintética virou tri-state (nenhum/parcial/completo) ### 122. Botão "validado" de uma conta/linha sintética virou tri-state (nenhum/parcial/completo)
Pedido explícito do usuário: numa conta/linha com filhos (sintética), marcar o próprio check não deveria mais só alternar True/False — passou a ter 3 estados calculados a partir dos descendentes (`dcEstadoValidacaoGrupo()`, nova): amarelo enquanto nem todos os descendentes estiverem validados (mesmo já tendo marcado a própria sintética), verde quando 100% dos descendentes estiverem validados. Ciclo de clique implementado em `dcClicarValidadoConta()`/`dcClicarValidadoLinha()` (novas): 1º clique marca só a sintética (fica amarela); clicar de novo (já amarela) pergunta "Deseja validar todas as contas deste grupo?" e, confirmado, valida tudo em lote (fica verde); clicar de novo (já verde) desmarca o grupo inteiro sem perguntar. Folhas (sem filhos) continuam com o toggle simples de antes. Detalhe completo no `CLAUDE.md` desta pasta. Pedido explícito do usuário: numa conta/linha com filhos (sintética), marcar o próprio check não deveria mais só alternar True/False — passou a ter 3 estados calculados a partir dos descendentes (`dcEstadoValidacaoGrupo()`, nova): amarelo enquanto nem todos os descendentes estiverem validados (mesmo já tendo marcado a própria sintética), verde quando 100% dos descendentes estiverem validados. Ciclo de clique implementado em `dcClicarValidadoConta()`/`dcClicarValidadoLinha()` (novas): 1º clique marca só a sintética (fica amarela); clicar de novo (já amarela) pergunta "Deseja validar todas as contas deste grupo?" e, confirmado, valida tudo em lote (fica verde); clicar de novo (já verde) desmarca o grupo inteiro sem perguntar. Folhas (sem filhos) continuam com o toggle simples de antes. Detalhe completo no `CLAUDE.md` desta pasta.
### 123. Demonstração Mensal (Análise Vertical) — nova aba na revisão e no 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.

View File

@ -22,7 +22,7 @@ O relatório Questor de Balancete + DRE tem uma particularidade de renderizaçã
- **Balancete**: cada linha casa com `_RE_LINHA_BALANCETE` (`^(conta)\s+(S)?\s*(código)\s+(resto)$`), e os últimos 4 tokens monetários de `resto` (via `_RE_MONETARIO`) são Saldo Anterior/Débito/Crédito/Saldo Atual, nessa ordem — o texto antes deles é a descrição. `tipo` é `"S"` (sintética) quando o flag aparece, `"A"` (analítica) quando não. - **Balancete**: cada linha casa com `_RE_LINHA_BALANCETE` (`^(conta)\s+(S)?\s*(código)\s+(resto)$`), e os últimos 4 tokens monetários de `resto` (via `_RE_MONETARIO`) são Saldo Anterior/Débito/Crédito/Saldo Atual, nessa ordem — o texto antes deles é a descrição. `tipo` é `"S"` (sintética) quando o flag aparece, `"A"` (analítica) quando não.
- **DRE**: cada linha é descrição + um único valor final (sem código de classificação, diferente do Balancete). `nivel` (indentação) é derivado do `x0` do primeiro caractere da linha, em relação ao menor `x0` visto na seção (a raiz, nível 0); `totalizador` é `True` quando algum caractere da linha usa fonte em negrito (`fontname` contendo `"bold"`, case-insensitive) — confirmado contra o PDF real: linhas como "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usam `Times-Bold`, as demais `Times-Roman`. - **DRE**: cada linha é descrição + um único valor final (sem código de classificação, diferente do Balancete). `nivel` (indentação) é derivado do `x0` do primeiro caractere da linha, em relação ao menor `x0` visto na seção (a raiz, nível 0); `totalizador` é `True` quando algum caractere da linha usa fonte em negrito (`fontname` contendo `"bold"`, case-insensitive) — confirmado contra o PDF real: linhas como "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usam `Times-Bold`, as demais `Times-Roman`.
- **Extração para no início da seção "Demonstração Mensal (Análise Vertical)"** (páginas finais do mesmo PDF, quando presentes) — de propósito: o histórico próprio do Portal cobre a mesma necessidade de forma mais confiável (qualquer competência anterior já processada, não só as últimas 3 meses que aquele relatório mostra). - **Demonstração Mensal (Análise Vertical)** (páginas finais do mesmo PDF, quando presentes) — **passou a ser extraída** (rodada 123; antes o parser parava aí de propósito, já que o histórico próprio do Portal cobre variação mês a mês de forma mais confiável). É a mesma árvore da DRE (mesma descrição/ordem/negrito), só que cada linha repete N pares "Valor Variação" (um por mês mostrado, ex. "mai - 2026 jun - 2026 jul - 2026" — normalmente os 3 meses até a competência do PDF) em vez de um valor único. `_RE_PAR_VALOR_VARIACAO` casa um par de cada vez (`(valor, percentual)`, na ordem em que aparecem na linha); `_RE_MES_ANALISE_VERTICAL` captura o cabeçalho de mês uma única vez (primeira página da seção — as páginas seguintes repetem o mesmo cabeçalho, ignorado depois da primeira captura). O nível de indentação usa o mesmo divisor `/7.0` da DRE, calibrado contra o mesmo PDF de referência. Ver "Análise Vertical" mais abaixo pra models/views/frontend/relatório.
- `LINHA_DRE_RECEITA_LIQUIDA`/`LINHA_DRE_CUSTOS_TOTAIS` (constantes em `parser.py`) guardam o texto exato dessas duas linhas totalizadoras (**com** o prefixo `"(=) "`/`"(-) "` que o Questor imprime) — usadas por `regra_percentual_custo_receita_atipico` pra achar a linha certa por texto, mais robusto a pequenas variações de nível/indentação entre empresas do que confiar só no negrito. - `LINHA_DRE_RECEITA_LIQUIDA`/`LINHA_DRE_CUSTOS_TOTAIS` (constantes em `parser.py`) guardam o texto exato dessas duas linhas totalizadoras (**com** o prefixo `"(=) "`/`"(-) "` que o Questor imprime) — usadas por `regra_percentual_custo_receita_atipico` pra achar a linha certa por texto, mais robusto a pequenas variações de nível/indentação entre empresas do que confiar só no negrito.
- `extrai_balancete_dre(origem)` aceita tanto um caminho em disco quanto um arquivo já aberto em memória (`io.BytesIO`) — a view chama isto **antes** de salvar qualquer coisa no banco, já que `codigo_empresa`/`competencia` (a chave natural da apuração) só são conhecidos depois de ler o PDF, não informados pelo usuário no upload (diferente de `IndicadorApuracao`, que recebe a competência como campo do formulário). - `extrai_balancete_dre(origem)` aceita tanto um caminho em disco quanto um arquivo já aberto em memória (`io.BytesIO`) — a view chama isto **antes** de salvar qualquer coisa no banco, já que `codigo_empresa`/`competencia` (a chave natural da apuração) só são conhecidos depois de ler o PDF, não informados pelo usuário no upload (diferente de `IndicadorApuracao`, que recebe a competência como campo do formulário).
@ -50,6 +50,7 @@ Padrão cabeçalho → linhas de detalhe → achados (mesma filosofia de `Indica
- **`ContabilApuracao`**: `codigo_empresa`/`nome_empresa`/`cnpj`/`competencia` (extraídos do PDF, não informados no upload) + `periodo_inicio`/`periodo_fim` + `arquivo` + `status` (`revisao`/`concluida`). `unique_together` em `codigo_empresa`+`competencia` — reprocessar a mesma competência de uma empresa exige excluir a apuração antiga primeiro (sem "reabrir"/reprocessar nesta v1, diferente de `ImportacaoPlanoSaude`). - **`ContabilApuracao`**: `codigo_empresa`/`nome_empresa`/`cnpj`/`competencia` (extraídos do PDF, não informados no upload) + `periodo_inicio`/`periodo_fim` + `arquivo` + `status` (`revisao`/`concluida`). `unique_together` em `codigo_empresa`+`competencia` — reprocessar a mesma competência de uma empresa exige excluir a apuração antiga primeiro (sem "reabrir"/reprocessar nesta v1, diferente de `ImportacaoPlanoSaude`).
- **`ContabilConta`**: uma linha do Balancete. `observacao` (`TextField`, editável via PATCH em **qualquer** conta, tenha ela gerado achado ou não) — é o espaço de "análise" pedido pelo usuário, independente da auditoria automática. `oculta_no_relatorio` (default `True`, invertido numa rodada — ver "Editor de observação inline" abaixo) e `validado` (`BooleanField`, default `False` — checkbox informativo de "já conferi esta conta", sem efeito em achado/observação/relatório). - **`ContabilConta`**: uma linha do Balancete. `observacao` (`TextField`, editável via PATCH em **qualquer** conta, tenha ela gerado achado ou não) — é o espaço de "análise" pedido pelo usuário, independente da auditoria automática. `oculta_no_relatorio` (default `True`, invertido numa rodada — ver "Editor de observação inline" abaixo) e `validado` (`BooleanField`, default `False` — checkbox informativo de "já conferi esta conta", sem efeito em achado/observação/relatório).
- **`ContabilLinhaDre`**: uma linha da DRE, sem código de classificação (o relatório não traz um pra DRE, diferente do Balancete). Mesmos `oculta_no_relatorio`/`validado` de `ContabilConta`. - **`ContabilLinhaDre`**: uma linha da DRE, sem código de classificação (o relatório não traz um pra DRE, diferente do Balancete). Mesmos `oculta_no_relatorio`/`validado` de `ContabilConta`.
- **`ContabilLinhaAnaliseVertical`** (rodada 123): uma linha da Demonstração Mensal (Análise Vertical) — mesma árvore/descrição/nível da DRE, mas `valores` (`JSONField`) guarda um `{"valor": "...", "percentual": "..."}` por mês em vez de um `DecimalField` único (gravado como texto, não float, pra não perder precisão), alinhado por posição com `ContabilApuracao.analise_vertical_meses` (ex.: `["mai/2026", "jun/2026", "jul/2026"]`, lista compartilhada por toda a apuração, não por linha). Mesmos `observacao`/`oculta_no_relatorio`/`validado` de `ContabilConta`/`ContabilLinhaDre` — recursos por linha idênticos (editor inline, tri-state, ocultar do relatório), decisão confirmada com o usuário via `AskUserQuestion` antes de implementar. Lista vazia (`analise_vertical_meses=[]`, nenhuma linha) quando o PDF não tinha essa seção — relatório antigo, ou empresa sem essa seção habilitada no Questor; a aba/tab correspondente some nesse caso (ver "Análise Vertical" abaixo).
- **`ContabilAchado`**: achado de auditoria, nasce automático em `create()`, nunca é apagado — só muda de `status` (`pendente`/`tratado`/`ignorado`), sempre com `observacao_contador` obrigatória ao mudar de pendente (mesmo espírito de "histórico completo preservado" de `ImportacaoPlanoSaudeAuditoria`). `conta` é nullable — achados 1 e 2 (balanceamento/débito-crédito) são gerais, sem uma conta específica. - **`ContabilAchado`**: achado de auditoria, nasce automático em `create()`, nunca é apagado — só muda de `status` (`pendente`/`tratado`/`ignorado`), sempre com `observacao_contador` obrigatória ao mudar de pendente (mesmo espírito de "histórico completo preservado" de `ImportacaoPlanoSaudeAuditoria`). `conta` é nullable — achados 1 e 2 (balanceamento/débito-crédito) são gerais, sem uma conta específica.
## `ContabilApuracaoViewSet.create()` — ordem de operações não-trivial ## `ContabilApuracaoViewSet.create()` — ordem de operações não-trivial
@ -318,3 +319,19 @@ Pedido explícito do usuário, com um exemplo real de texto que a De Paula já m
**Frontend — relatório** (`dashboard-contabil-relatorio.html`): `{{ apuracao.resumo_fechamento|safe }}` dentro de `.dcr-resumo-fechamento__texto`, condicionado a `{% if apuracao.resumo_fechamento %}` (nada renderiza se a apuração não tem resumo). **Primeiro `|safe` de template Django do projeto** — até agora todo texto rico só existia em tela SPA, injetado via `.innerHTML =` no JS, nunca por um template renderizado no servidor; seguro aqui pelo mesmo motivo de sempre (já vem sanitizado por nh3 antes de salvar, nunca cru do request). Seção com fundo/borda/sombra próprios (`.dcr-resumo-fechamento`, visual de card, diferente das outras `.dcr-secao` que são só agrupamento sem fundo) — se destaca do restante da aba antes dos grupos de indicador. **Frontend — relatório** (`dashboard-contabil-relatorio.html`): `{{ apuracao.resumo_fechamento|safe }}` dentro de `.dcr-resumo-fechamento__texto`, condicionado a `{% if apuracao.resumo_fechamento %}` (nada renderiza se a apuração não tem resumo). **Primeiro `|safe` de template Django do projeto** — até agora todo texto rico só existia em tela SPA, injetado via `.innerHTML =` no JS, nunca por um template renderizado no servidor; seguro aqui pelo mesmo motivo de sempre (já vem sanitizado por nh3 antes de salvar, nunca cru do request). Seção com fundo/borda/sombra próprios (`.dcr-resumo-fechamento`, visual de card, diferente das outras `.dcr-secao` que são só agrupamento sem fundo) — se destaca do restante da aba antes dos grupos de indicador.
Testado de ponta a ponta via `Client.force_login()` num shell: `POST .../resumo-fechamento/` com um `<script>alert(1)</script>` embutido junto de HTML válido — confirmado que o nh3 removeu o `<script>` e manteve `<p>`/`<strong>`/`<ul>`/`<li>` intactos; conferido que o texto aparece no relatório antes de "Indicadores de Resultado" e que a seção some quando `resumo_fechamento` está vazio; revertido pro valor original (vazio) na apuração real (`id=4`) ao final do teste. Testado de ponta a ponta via `Client.force_login()` num shell: `POST .../resumo-fechamento/` com um `<script>alert(1)</script>` embutido junto de HTML válido — confirmado que o nh3 removeu o `<script>` e manteve `<p>`/`<strong>`/`<ul>`/`<li>` intactos; conferido que o texto aparece no relatório antes de "Indicadores de Resultado" e que a seção some quando `resumo_fechamento` está vazio; revertido pro valor original (vazio) na apuração real (`id=4`) ao final do teste.
### Análise Vertical — nova aba na revisão e no relatório (rodada 123)
Pedido explícito do usuário: a Demonstração Mensal (Análise Vertical), até então ignorada pelo parser (ver "Extração do PDF" acima), passou a ser exibida tanto na tela de revisão quanto no relatório "Gerar Dashboard" — 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).
**Backend**: `ContabilLinhaAnaliseVerticalViewSet` (`GET`/`PATCH` em `/api/contabil-linhas-analise-vertical/{id}/`) é uma réplica exata de `ContabilLinhaDreViewSet` — mesmo `_contabil_garante_em_revisao()`, mesmo serializer com `valores` read-only. `ContabilApuracaoViewSet.create()` faz um terceiro `bulk_create` (depois de `ContabilConta`/`ContabilLinhaDre`) com as linhas de `resultado.extracao.linhas_analise_vertical`, e grava `analise_vertical_meses` já na criação da `ContabilApuracao` — vem pronto do parser, não precisa de nenhum cálculo adicional na view. `dashboard()` ganhou `linhas_analise_vertical`/`analise_vertical_meses`/`observacoes_analise_vertical` no contexto, reaproveitando `_contabil_arvore_contexto()` (mesma função da DRE, só que passando a lista/nivel_fn certos) — nenhuma função de árvore nova precisou ser escrita.
**Frontend — tela de revisão** (`dashboard-contabil.html`/`.js`): nova aba `data-dc-tab="analise-vertical"` (botão `#dc-av-tab-btn`, `hidden` quando `analise_vertical_meses` está vazio — relatório antigo sem essa seção não ganha uma aba com tabela vazia). `renderAnaliseVertical()` é uma réplica de `renderDre()` (mesmo algoritmo de pilha de colapso, mesmo tri-state de validado via `dcValidadoInfo()`/`dcClicarValidadoAv()` — as funções genéricas já existentes, `dcColapsoPadrao`/`dcUltimaLevaVisivel`/`dcFilhosDiretos`/`dcDescendentes`/`dcEstadoValidacaoGrupo`/`dcValidadoInfo`, já recebiam `itens`/`nivelFn` como parâmetro e foram 100% reaproveitadas sem alteração), só que cada `<td>` de valor vira **N pares** de colunas (Valor/Variação, uma dupla por mês) montadas a partir de `linha.valores` — o cabeçalho da tabela (`renderAnaliseVerticalHead()`) também é montado em JS porque o número de meses varia (normalmente 3, mas não é fixo). `pidDcFormatPercentualAnaliseVertical()` é um formatador **novo**, deliberadamente diferente de `pidDcFormatIndicadorPercentual()` — o percentual da Análise Vertical já vem "pronto" do PDF (ex. `"100.00"` quer dizer 100,00%), enquanto o dos cards de indicador é uma fração que precisa ser multiplicada por 100; reusar o errado exibiria `10000,00%`. Editor de observação inline + resumo de observações no fim da aba seguem exatamente o padrão de Balancete/D.R.E. (`dc-av-obs-*`).
**Se a apuração aberta não tem Análise Vertical mas a aba estava ativa** (ex.: usuário estava vendo essa aba de uma apuração anterior e abre uma sem essa seção) — `renderRevisao()` força a volta pra aba "Observações" nesse caso específico, pra não deixar um botão de aba escondido com o painel dele ainda visível.
**Frontend — relatório** (`dashboard-contabil-relatorio.html`): 4ª aba `data-dcr-tab="analise-vertical"`, entre "D.R.E." e "Resumo" — todo o bloco (botão + painel) fica dentro de `{% if analise_vertical_meses %}`, então some inteiro em relatórios de apurações antigas sem essa seção; `pidDcrArvore("dcr-av-body")`/`pidDcrObs("dcr-av-body")` (chamadas incondicionais no fim do script) já toleram um `tbody` inexistente (`if (!tbody) return;`, mesma guarda que essas duas funções já tinham). Reaproveita a mesma árvore recolhível/observação inline da DRE, sem nenhuma função nova. "Observações da Análise Vertical" tem sua própria seção no fim da aba, e a lista consolidada "Todas as Observações da Análise" (aba "Resumo") ganhou um 3º grupo (prefixo "Análise Vertical — ...", ícone de gráfico de barras) ao lado de Balancete/D.R.E./Auditoria — a mesma extensão simples de sempre, um `{% for %}` a mais.
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.

View File

@ -2,13 +2,13 @@
> 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. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, gráfico de evolução, DRE/Balancete 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.). 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).
## Onde mexer ## Onde mexer
- `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF via `pdfplumber`), `regras.py` (motor de regras de auditoria), `indicadores.py` (indicadores financeiros do relatório), `pipeline.py` (orquestração), `modelos.py` (dataclasses). - `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF via `pdfplumber`), `regras.py` (motor de regras de auditoria), `indicadores.py` (indicadores financeiros do relatório), `pipeline.py` (orquestração), `modelos.py` (dataclasses).
- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilAchado` (`portal_api/models.py`). - `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`/`ContabilAchado` (`portal_api/models.py`).
- `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`. - `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`.
- `dashboard-contabil-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação). - `dashboard-contabil-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação).

View File

@ -40,11 +40,33 @@ class LinhaDreExtraida:
totalizador: bool totalizador: bool
@dataclass
class ValorMensalAnaliseVertical:
valor: Decimal
percentual: Decimal
@dataclass
class LinhaAnaliseVerticalExtraida:
"""Uma linha da seção "Demonstração Mensal (Análise Vertical)" — mesma
descrição/nível/negrito de `LinhaDreExtraida` (é a mesma árvore da DRE),
só que com um valor+percentual por mês em vez de um valor único (ver
`parser.py`)."""
ordem: int
descricao: str
nivel: int
totalizador: bool
valores: list[ValorMensalAnaliseVertical]
@dataclass @dataclass
class ResultadoExtracao: class ResultadoExtracao:
cabecalho: CabecalhoExtraido cabecalho: CabecalhoExtraido
contas: list[LinhaBalanceteExtraida] contas: list[LinhaBalanceteExtraida]
linhas_dre: list[LinhaDreExtraida] linhas_dre: list[LinhaDreExtraida]
meses_analise_vertical: list[str] = field(default_factory=list)
linhas_analise_vertical: list[LinhaAnaliseVerticalExtraida] = field(default_factory=list)
@dataclass @dataclass

View File

@ -24,7 +24,14 @@ from typing import IO
import pdfplumber import pdfplumber
from .modelos import CabecalhoExtraido, LinhaBalanceteExtraida, LinhaDreExtraida, ResultadoExtracao from .modelos import (
CabecalhoExtraido,
LinhaAnaliseVerticalExtraida,
LinhaBalanceteExtraida,
LinhaDreExtraida,
ResultadoExtracao,
ValorMensalAnaliseVertical,
)
# Vão horizontal (em pontos) acima do qual dois caracteres consecutivos são # Vão horizontal (em pontos) acima do qual dois caracteres consecutivos são
# tratados como pertencendo a campos/palavras diferentes. Calibrado contra o # tratados como pertencendo a campos/palavras diferentes. Calibrado contra o
@ -42,6 +49,22 @@ _RE_CNPJ = re.compile(r"CNPJ:\s*([\d./-]+)")
_RE_PERIODO = re.compile(r"Per[ií]odo:\s*(\d{2}/\d{2}/\d{4})\s*a\s*(\d{2}/\d{2}/\d{4})") _RE_PERIODO = re.compile(r"Per[ií]odo:\s*(\d{2}/\d{2}/\d{4})\s*a\s*(\d{2}/\d{2}/\d{4})")
_RE_CODIGO_NOME_EMPRESA = re.compile(r"^(?P<codigo>\d+)\s+(?P<nome>.+)$") _RE_CODIGO_NOME_EMPRESA = re.compile(r"^(?P<codigo>\d+)\s+(?P<nome>.+)$")
# Uma linha da Análise Vertical repete N pares "Valor Variação" (um por mês
# mostrado) em vez do valor único da DRE. O valor às vezes traz um espaço
# residual antes do parêntese de fechamento (ex. "(552.338,51 )") — artefato
# do mesmo gerador de relatório, `Decimal()` já tolera esse espaço; a
# variação é sempre um percentual com "%" colado ao número. Calibrado contra
# `792 - balancete 072026.pdf` (mesmo arquivo de referência da DRE/Balancete).
_TOKEN_VALOR_ANALISE_VERTICAL = r"\(?-?[\d.]+,\d{2}\s?\)?"
_TOKEN_PERCENTUAL_ANALISE_VERTICAL = r"\(?-?[\d.]+,\d{2}\s?%\s?\)?"
_RE_PAR_VALOR_VARIACAO = re.compile(
rf"({_TOKEN_VALOR_ANALISE_VERTICAL})\s+({_TOKEN_PERCENTUAL_ANALISE_VERTICAL})"
)
# Cabeçalho de mês da Análise Vertical, ex. "mai - 2026 jun - 2026 jul - 2026"
# — capturado uma única vez (primeira página da seção), as páginas seguintes
# repetem o mesmo cabeçalho a cada quebra de página.
_RE_MES_ANALISE_VERTICAL = re.compile(r"([A-Za-z]{3})\s*-\s*(\d{4})")
# Linhas totalizadoras conhecidas da DRE (usadas por regras que precisam de # Linhas totalizadoras conhecidas da DRE (usadas por regras que precisam de
# uma linha específica, ex. razão custo/receita) — além do negrito (que já # uma linha específica, ex. razão custo/receita) — além do negrito (que já
# identifica visualmente todo totalizador pro frontend), casar por texto é # identifica visualmente todo totalizador pro frontend), casar por texto é
@ -84,6 +107,13 @@ def _para_decimal(texto: str) -> Decimal:
return -valor if negativo else valor return -valor if negativo else valor
def _para_percentual(texto: str) -> Decimal:
"""Mesma conversão de `_para_decimal`, só removendo o "%" antes — o sinal
negativo continua expresso só pelo parêntese (ex. "(7,33%)"), igual ao
valor monetário."""
return _para_decimal(texto.replace("%", ""))
def _para_data(texto: str) -> date: def _para_data(texto: str) -> date:
dia, mes, ano = texto.split("/") dia, mes, ano = texto.split("/")
return date(int(ano), int(mes), int(dia)) return date(int(ano), int(mes), int(dia))
@ -156,18 +186,25 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
sobre o upload, antes de saber `codigo_empresa`/`competencia` (extraídos sobre o upload, antes de saber `codigo_empresa`/`competencia` (extraídos
daqui), então ainda não há um caminho em disco definitivo nesse momento. daqui), então ainda não há um caminho em disco definitivo nesse momento.
As páginas finais de "Demonstração Mensal (Análise Vertical)", quando As páginas finais de "Demonstração Mensal (Análise Vertical)", quando
presentes, são ignoradas de propósito — o Dashboard Contábil calcula presentes, também são extraídas (`linhas_analise_vertical`/
variação mês a mês a partir do próprio histórico de apurações já `meses_analise_vertical`) — é a mesma árvore da DRE, só que com um
processadas no Portal, não a partir desse relatório complementar (ver valor+percentual por mês em vez de um valor único; **não** substitui o
CLAUDE.md/plano.md).""" histórico próprio do Portal usado pelas regras de variação mês a mês
(`regras.py` continua comparando contra apurações anteriores já
persistidas, não contra esta seção), só alimenta a aba/relatório
"Análise Vertical" com os mesmos meses que o próprio Questor já calcula."""
cabecalho: CabecalhoExtraido | None = None cabecalho: CabecalhoExtraido | None = None
contas: list[LinhaBalanceteExtraida] = [] contas: list[LinhaBalanceteExtraida] = []
linhas_dre: list[LinhaDreExtraida] = [] linhas_dre: list[LinhaDreExtraida] = []
linhas_analise_vertical: list[LinhaAnaliseVerticalExtraida] = []
meses_analise_vertical: list[str] | None = None
secao = "cabecalho" secao = "cabecalho"
ordem_dre = 0 ordem_dre = 0
ordem_av = 0
base_x0_dre: float | None = None base_x0_dre: float | None = None
base_x0_av: float | None = None
with pdfplumber.open(origem) as pdf: with pdfplumber.open(origem) as pdf:
if not pdf.pages: if not pdf.pages:
@ -185,9 +222,17 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
continue continue
if texto.startswith(_TITULO_ANALISE_VERTICAL): if texto.startswith(_TITULO_ANALISE_VERTICAL):
secao = "ignorar" if secao != "analise_vertical":
break secao = "analise_vertical"
if secao != "dre" and texto.startswith(_TITULO_DRE): base_x0_av = None
continue
# Guarda só contra cabeçalho/balancete pra não disparar de
# novo quando o título da DRE se repete no topo de cada
# página já dentro da seção "dre" (comportamento de sempre)
# ou da Análise Vertical, que também reimprime esse título —
# sem essa restrição, `secao` voltaria de "analise_vertical"
# pra "dre" a cada quebra de página da Análise Vertical.
if secao in ("cabecalho", "balancete") and texto.startswith(_TITULO_DRE):
secao = "dre" secao = "dre"
base_x0_dre = None base_x0_dre = None
continue continue
@ -243,12 +288,59 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
totalizador=linha["negrito"], totalizador=linha["negrito"],
) )
) )
if secao == "ignorar": elif secao == "analise_vertical":
break # O cabeçalho de mês ("mai - 2026 jun - 2026 jul - 2026")
# só existe uma vez de verdade — repete a cada quebra de
# página, mas só a primeira ocorrência importa (captura
# os meses e não é tratada como linha de dado, já que
# não tem par valor+variação nenhum). Qualquer outra
# linha sem nenhum par (título repetido, cabeçalho de
# coluna "Descrição/Valor/Variação") é ignorada do mesmo
# jeito que a DRE ignora linhas sem valor monetário.
if meses_analise_vertical is None:
candidatos_mes = _RE_MES_ANALISE_VERTICAL.findall(texto)
if candidatos_mes:
meses_analise_vertical = [
f"{mes.lower()}/{ano}" for mes, ano in candidatos_mes
]
continue
pares = _RE_PAR_VALOR_VARIACAO.findall(texto)
if not pares:
continue
posicao = texto.find(pares[0][0])
if posicao == -1:
continue
descricao = texto[:posicao].strip()
if not descricao:
continue
if base_x0_av is None:
base_x0_av = linha["x0"]
nivel = max(0, round((linha["x0"] - base_x0_av) / 7.0))
ordem_av += 1
linhas_analise_vertical.append(
LinhaAnaliseVerticalExtraida(
ordem=ordem_av,
descricao=descricao,
nivel=nivel,
totalizador=linha["negrito"],
valores=[
ValorMensalAnaliseVertical(
valor=_para_decimal(valor_texto), percentual=_para_percentual(percentual_texto)
)
for valor_texto, percentual_texto in pares
],
)
)
if not contas: if not contas:
raise ExtracaoInvalidaError("Nenhuma linha de balancete encontrada no PDF.") raise ExtracaoInvalidaError("Nenhuma linha de balancete encontrada no PDF.")
if not linhas_dre: if not linhas_dre:
raise ExtracaoInvalidaError("Nenhuma linha de DRE encontrada no PDF.") raise ExtracaoInvalidaError("Nenhuma linha de DRE encontrada no PDF.")
return ResultadoExtracao(cabecalho=cabecalho, contas=contas, linhas_dre=linhas_dre) return ResultadoExtracao(
cabecalho=cabecalho,
contas=contas,
linhas_dre=linhas_dre,
meses_analise_vertical=meses_analise_vertical or [],
linhas_analise_vertical=linhas_analise_vertical,
)

View File

@ -0,0 +1,39 @@
# Generated by Django 6.0.7 on 2026-09-11 12:20
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0066_alter_contabilachado_options_and_more'),
]
operations = [
migrations.AddField(
model_name='contabilapuracao',
name='analise_vertical_meses',
field=models.JSONField(blank=True, default=list, verbose_name='Meses da Análise Vertical'),
),
migrations.CreateModel(
name='ContabilLinhaAnaliseVertical',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ordem', models.IntegerField(verbose_name='Ordem')),
('descricao', models.CharField(max_length=255, verbose_name='Descrição')),
('nivel', models.IntegerField(default=0, verbose_name='Nível de indentação')),
('totalizador', models.BooleanField(default=False, verbose_name='Linha totalizadora')),
('valores', models.JSONField(default=list, verbose_name='Valores mensais')),
('observacao', models.TextField(blank=True, verbose_name='Observação')),
('oculta_no_relatorio', models.BooleanField(default=True, verbose_name='Observação oculta no relatório')),
('validado', models.BooleanField(default=False, verbose_name='Validado pelo contador')),
('apuracao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='linhas_analise_vertical', to='portal_api.contabilapuracao')),
],
options={
'verbose_name': 'Linha da Análise Vertical (Relatório Contábil)',
'verbose_name_plural': 'Linhas da Análise Vertical (Relatório Contábil)',
'ordering': ['ordem'],
},
),
]

View File

@ -2013,6 +2013,13 @@ class ContabilApuracao(models.Model):
resumo_fechamento = models.TextField( resumo_fechamento = models.TextField(
"Resumo do Fechamento", blank=True, validators=[validar_tamanho_resumo_fechamento_contabil] "Resumo do Fechamento", blank=True, validators=[validar_tamanho_resumo_fechamento_contabil]
) )
# Rótulos dos meses da seção "Demonstração Mensal (Análise Vertical)" do
# PDF (ex.: ["mai/2026", "jun/2026", "jul/2026"]), na mesma ordem/posição
# dos valores de cada ContabilLinhaAnaliseVertical.valores — compartilhado
# aqui (não por linha) porque é sempre o mesmo conjunto de meses pra toda
# a apuração. Lista vazia quando o PDF não tinha essa seção (relatório
# mais antigo, ou empresa sem essa seção habilitada no Questor).
analise_vertical_meses = models.JSONField("Meses da Análise Vertical", default=list, blank=True)
class Meta: class Meta:
verbose_name = "Apuração do Relatório Contábil" verbose_name = "Apuração do Relatório Contábil"
@ -2101,6 +2108,36 @@ class ContabilLinhaDre(models.Model):
return f"{self.descricao}" return f"{self.descricao}"
class ContabilLinhaAnaliseVertical(models.Model):
"""Uma linha da seção "Demonstração Mensal (Análise Vertical)" do mesmo
PDF — mesma árvore/descrição/nível da DRE (`ContabilLinhaDre`), só que
com um valor+percentual por mês em vez de um valor único, por isso
`valores` é uma lista (não um `DecimalField` só). Cada posição da lista
corresponde à mesma posição em `ContabilApuracao.analise_vertical_meses`
(ex.: `valores[0]` é o mês `analise_vertical_meses[0]`). Gravado como
texto (não float), pra não perder precisão decimal em nenhuma conversão
— `[{"valor": "1234.56", "percentual": "12.34"}, ...]`."""
apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="linhas_analise_vertical")
ordem = models.IntegerField("Ordem")
descricao = models.CharField("Descrição", max_length=255)
nivel = models.IntegerField("Nível de indentação", default=0)
totalizador = models.BooleanField("Linha totalizadora", default=False)
valores = models.JSONField("Valores mensais", default=list)
observacao = models.TextField("Observação", blank=True)
# Mesmo espírito de ContabilLinhaDre.oculta_no_relatorio/.validado.
oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=True)
validado = models.BooleanField("Validado pelo contador", default=False)
class Meta:
verbose_name = "Linha da Análise Vertical (Relatório Contábil)"
verbose_name_plural = "Linhas da Análise Vertical (Relatório Contábil)"
ordering = ["ordem"]
def __str__(self) -> str:
return f"{self.descricao}"
class ContabilAchado(models.Model): class ContabilAchado(models.Model):
"""Um achado gerado automaticamente pelo motor de regras """Um achado gerado automaticamente pelo motor de regras
(`dashboard_contabil.regras`) ao criar a apuração — nunca é apagado, só (`dashboard_contabil.regras`) ao criar a apuração — nunca é apagado, só

View File

@ -19,6 +19,7 @@ from .models import (
ContabilAchado, ContabilAchado,
ContabilApuracao, ContabilApuracao,
ContabilConta, ContabilConta,
ContabilLinhaAnaliseVertical,
ContabilLinhaDre, ContabilLinhaDre,
Departamento, Departamento,
EmpresaQuestor, EmpresaQuestor,
@ -2259,6 +2260,13 @@ class ContabilLinhaDreSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador"] read_only_fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador"]
class ContabilLinhaAnaliseVerticalSerializer(serializers.ModelSerializer):
class Meta:
model = ContabilLinhaAnaliseVertical
fields = ["id", "ordem", "descricao", "nivel", "totalizador", "valores", "observacao", "oculta_no_relatorio", "validado"]
read_only_fields = ["id", "ordem", "descricao", "nivel", "totalizador", "valores"]
class ContabilAchadoSerializer(serializers.ModelSerializer): class ContabilAchadoSerializer(serializers.ModelSerializer):
conta_codigo = serializers.CharField(source="conta.codigo", read_only=True, default=None) conta_codigo = serializers.CharField(source="conta.codigo", read_only=True, default=None)
conta_descricao = serializers.CharField(source="conta.descricao", read_only=True, default=None) conta_descricao = serializers.CharField(source="conta.descricao", read_only=True, default=None)
@ -2541,6 +2549,7 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None) criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
contas = ContabilContaSerializer(many=True, read_only=True) contas = ContabilContaSerializer(many=True, read_only=True)
linhas_dre = ContabilLinhaDreSerializer(many=True, read_only=True) linhas_dre = ContabilLinhaDreSerializer(many=True, read_only=True)
linhas_analise_vertical = ContabilLinhaAnaliseVerticalSerializer(many=True, read_only=True)
achados = ContabilAchadoSerializer(many=True, read_only=True) achados = ContabilAchadoSerializer(many=True, read_only=True)
class Meta: class Meta:
@ -2560,5 +2569,7 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
"resumo_fechamento", "resumo_fechamento",
"contas", "contas",
"linhas_dre", "linhas_dre",
"analise_vertical_meses",
"linhas_analise_vertical",
"achados", "achados",
] ]

View File

@ -47,6 +47,27 @@ def indice(valor: Decimal | None) -> str:
return _agrupa_milhar(f"{valor:,.2f}") return _agrupa_milhar(f"{valor:,.2f}")
@register.filter
def moeda_av(valor: str | None) -> str:
"""Mesmo formato de `moeda` acima, só que recebendo o valor mensal da
Análise Vertical como ele é gravado (texto, não Decimal — ver
`ContabilLinhaAnaliseVertical.valores`, um JSONField)."""
if not valor:
return "—"
return moeda(Decimal(valor))
@register.filter
def percentual_av(valor: str | None) -> str:
"""Percentual da Análise Vertical já vem pronto do próprio relatório
(ex. "100.00" quer dizer 100,00%) — diferente do filtro `percentual`
acima, que recebe uma fração (ex. 0.50) e multiplica por 100; aqui só
formata no padrão brasileiro."""
if not valor:
return "—"
return f"{_agrupa_milhar(f'{Decimal(valor):,.2f}')}%"
@register.filter @register.filter
def competencia(valor: date | None) -> str: def competencia(valor: date | None) -> str:
return valor.strftime("%m/%Y") if valor else "—" return valor.strftime("%m/%Y") if valor else "—"

View File

@ -98,6 +98,11 @@ router.register("nao-conformidades/acoes", views.NCAcaoViewSet, basename="nc-aca
router.register("contabil-apuracoes", views.ContabilApuracaoViewSet, basename="contabil-apuracao") router.register("contabil-apuracoes", views.ContabilApuracaoViewSet, basename="contabil-apuracao")
router.register("contabil-contas", views.ContabilContaViewSet, basename="contabil-conta") router.register("contabil-contas", views.ContabilContaViewSet, basename="contabil-conta")
router.register("contabil-linhas-dre", views.ContabilLinhaDreViewSet, basename="contabil-linha-dre") router.register("contabil-linhas-dre", views.ContabilLinhaDreViewSet, basename="contabil-linha-dre")
router.register(
"contabil-linhas-analise-vertical",
views.ContabilLinhaAnaliseVerticalViewSet,
basename="contabil-linha-analise-vertical",
)
router.register("contabil-achados", views.ContabilAchadoViewSet, basename="contabil-achado") router.register("contabil-achados", views.ContabilAchadoViewSet, basename="contabil-achado")
router.register( router.register(
"contabil-indicadores-definicoes", "contabil-indicadores-definicoes",

View File

@ -54,6 +54,7 @@ from .models import (
ContabilAchado, ContabilAchado,
ContabilApuracao, ContabilApuracao,
ContabilConta, ContabilConta,
ContabilLinhaAnaliseVertical,
ContabilLinhaDre, ContabilLinhaDre,
Departamento, Departamento,
Favorito, Favorito,
@ -132,6 +133,7 @@ from .serializers import (
ContabilIndicadoresOcultosSerializer, ContabilIndicadoresOcultosSerializer,
ContabilIndicadoresSelecionadosSerializer, ContabilIndicadoresSelecionadosSerializer,
ContabilResumoFechamentoSerializer, ContabilResumoFechamentoSerializer,
ContabilLinhaAnaliseVerticalSerializer,
ContabilLinhaDreSerializer, ContabilLinhaDreSerializer,
DepartamentoSerializer, DepartamentoSerializer,
FavoritoSerializer, FavoritoSerializer,
@ -3729,7 +3731,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
def get_queryset(self) -> QuerySet[ContabilApuracao]: def get_queryset(self) -> QuerySet[ContabilApuracao]:
queryset = super().get_queryset() queryset = super().get_queryset()
if self.action == "retrieve": if self.action == "retrieve":
queryset = queryset.prefetch_related("contas", "linhas_dre", "achados__conta") queryset = queryset.prefetch_related(
"contas", "linhas_dre", "linhas_analise_vertical", "achados__conta"
)
return queryset return queryset
def get_serializer_class(self) -> type[ModelSerializer]: def get_serializer_class(self) -> type[ModelSerializer]:
@ -3782,6 +3786,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
periodo_fim=cabecalho.periodo_fim, periodo_fim=cabecalho.periodo_fim,
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,
) )
ContabilConta.objects.bulk_create( ContabilConta.objects.bulk_create(
[ [
@ -3813,6 +3818,22 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
for linha in resultado.extracao.linhas_dre for linha in resultado.extracao.linhas_dre
] ]
) )
ContabilLinhaAnaliseVertical.objects.bulk_create(
[
ContabilLinhaAnaliseVertical(
apuracao=apuracao,
ordem=linha.ordem,
descricao=linha.descricao,
nivel=linha.nivel,
totalizador=linha.totalizador,
valores=[
{"valor": str(valor_mensal.valor), "percentual": str(valor_mensal.percentual)}
for valor_mensal in linha.valores
],
)
for linha in resultado.extracao.linhas_analise_vertical
]
)
contas_por_codigo = {conta.codigo: conta for conta in apuracao.contas.all()} contas_por_codigo = {conta.codigo: conta for conta in apuracao.contas.all()}
ContabilAchado.objects.bulk_create( ContabilAchado.objects.bulk_create(
[ [
@ -3865,6 +3886,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
apuracao = self.get_object() apuracao = self.get_object()
contas = list(apuracao.contas.order_by("ordem")) contas = list(apuracao.contas.order_by("ordem"))
linhas_dre = list(apuracao.linhas_dre.order_by("ordem")) linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
linhas_analise_vertical = list(apuracao.linhas_analise_vertical.order_by("ordem"))
achados = list(apuracao.achados.select_related("conta", "tratado_por").order_by("severidade", "id")) achados = list(apuracao.achados.select_related("conta", "tratado_por").order_by("severidade", "id"))
dados = _contabil_coleta_dados_indicadores(apuracao) dados = _contabil_coleta_dados_indicadores(apuracao)
@ -3894,8 +3916,15 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
"evolucao": evolucao, "evolucao": evolucao,
"contas": _contabil_arvore_contexto(contas, "conta", lambda c: c.codigo.count("."), 18), "contas": _contabil_arvore_contexto(contas, "conta", lambda c: c.codigo.count("."), 18),
"linhas_dre": _contabil_arvore_contexto(linhas_dre, "linha", lambda l: max(0, l.nivel), 16), "linhas_dre": _contabil_arvore_contexto(linhas_dre, "linha", lambda l: max(0, l.nivel), 16),
"analise_vertical_meses": apuracao.analise_vertical_meses,
"linhas_analise_vertical": _contabil_arvore_contexto(
linhas_analise_vertical, "linha", lambda l: max(0, l.nivel), 16
),
"observacoes_contas": [conta for conta in contas if conta.observacao and not conta.oculta_no_relatorio], "observacoes_contas": [conta for conta in contas if conta.observacao and not conta.oculta_no_relatorio],
"observacoes_dre": [linha for linha in linhas_dre if linha.observacao and not linha.oculta_no_relatorio], "observacoes_dre": [linha for linha in linhas_dre if linha.observacao and not linha.oculta_no_relatorio],
"observacoes_analise_vertical": [
linha for linha in linhas_analise_vertical if linha.observacao and not linha.oculta_no_relatorio
],
"achados_com_observacao": [ "achados_com_observacao": [
achado for achado in achados if achado.observacao_contador and not achado.oculto_no_relatorio achado for achado in achados if achado.observacao_contador and not achado.oculto_no_relatorio
], ],
@ -4148,6 +4177,23 @@ class ContabilLinhaDreViewSet(viewsets.ModelViewSet):
serializer.save() serializer.save()
class ContabilLinhaAnaliseVerticalViewSet(viewsets.ModelViewSet):
"""Linhas da Análise Vertical de uma apuração — nascem todas juntas em
`ContabilApuracaoViewSet.create()`, então só GET/PATCH (observação do
contador) aqui, mesmo espírito de `ContabilLinhaDreViewSet`."""
http_method_names = ["get", "patch", "head", "options"]
queryset = ContabilLinhaAnaliseVertical.objects.all()
serializer_class = ContabilLinhaAnaliseVerticalSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def perform_update(self, serializer: ContabilLinhaAnaliseVerticalSerializer) -> None:
_contabil_garante_em_revisao(serializer.instance.apuracao)
serializer.save()
class ContabilAchadoViewSet(viewsets.ModelViewSet): class ContabilAchadoViewSet(viewsets.ModelViewSet):
"""Achados de auditoria gerados automaticamente na criação da apuração — """Achados de auditoria gerados automaticamente na criação da apuração —
GET/PATCH (marcar como tratado/ignorado, sempre com justificativa) + GET/PATCH (marcar como tratado/ignorado, sempre com justificativa) +

View File

@ -560,6 +560,16 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* Análise Vertical tem um número variável de colunas de Valor/Variação (uma
dupla por mês, ver ContabilApuracao.analise_vertical_meses) — diferente
da DRE (sempre uma única coluna de valor, `td:nth-child(2)` acima), então
alinha à direita toda coluna que não seja a 1ª (descrição) nem a última
(observação), em vez de fixar um índice. */
.dc-av-table td:not(:first-child):not(:last-child) {
text-align: right;
font-variant-numeric: tabular-nums;
}
.dc-contas-table td:nth-child(1) { .dc-contas-table td:nth-child(1) {
white-space: nowrap; white-space: nowrap;
color: var(--text-muted); color: var(--text-muted);

View File

@ -117,6 +117,21 @@ const PID_DC_ICON_EDITAR =
const PID_DC_ICON_LIXEIRA = const PID_DC_ICON_LIXEIRA =
'<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>';
// Percentual da Análise Vertical já vem "pronto" do PDF (ex. "-7.33" quer
// dizer -7,33%, não -733%) — diferente de pidDcFormatIndicadorPercentual
// (usado pelos cards de indicador), que recebe uma fração e multiplica por
// 100 antes de exibir. Reusar aquele aqui exibiria "-733,00%" errado.
function pidDcFormatPercentualAnaliseVertical(valor) {
const numero = Number(valor || 0);
return `${numero.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
}
// "mai/2026" (formato salvo pelo parser) -> "Mai/2026" (rótulo de coluna).
function pidDcFormatMesAnaliseVertical(mes) {
if (!mes) return "";
return mes.charAt(0).toUpperCase() + mes.slice(1);
}
function pidDcFormatCompetencia(iso) { function pidDcFormatCompetencia(iso) {
if (!iso) return ""; if (!iso) return "";
const [ano, mes] = iso.split("-"); const [ano, mes] = iso.split("-");
@ -188,6 +203,14 @@ async function pidAtualizarValidadoLinhaDreContabil(id, validado) {
return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { validado } }); return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { validado } });
} }
async function pidAtualizarObservacaoLinhaAnaliseVerticalContabil(id, payload) {
return pidApiRequest(`/contabil-linhas-analise-vertical/${id}/`, { method: "PATCH", body: payload });
}
async function pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, validado) {
return pidApiRequest(`/contabil-linhas-analise-vertical/${id}/`, { method: "PATCH", body: { validado } });
}
async function pidAtualizarAchadoContabil(id, status, observacaoContador) { async function pidAtualizarAchadoContabil(id, status, observacaoContador) {
return pidApiRequest(`/contabil-achados/${id}/`, { return pidApiRequest(`/contabil-achados/${id}/`, {
method: "PATCH", method: "PATCH",
@ -216,6 +239,13 @@ async function pidAlternarOcultoLinhaDreContabil(id, oculto) {
return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { oculta_no_relatorio: oculto } }); return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { oculta_no_relatorio: oculto } });
} }
async function pidAlternarOcultoLinhaAnaliseVerticalContabil(id, oculto) {
return pidApiRequest(`/contabil-linhas-analise-vertical/${id}/`, {
method: "PATCH",
body: { oculta_no_relatorio: oculto },
});
}
async function pidAlternarOcultoAchadoContabil(id, oculto) { async function pidAlternarOcultoAchadoContabil(id, oculto) {
return pidApiRequest(`/contabil-achados/${id}/alternar-oculto/`, { return pidApiRequest(`/contabil-achados/${id}/alternar-oculto/`, {
method: "POST", method: "POST",
@ -305,6 +335,11 @@ document.addEventListener("DOMContentLoaded", async () => {
// fora do popup (pedido explícito do usuário). null = nenhum editor aberto. // fora do popup (pedido explícito do usuário). null = nenhum editor aberto.
let dcContaObsEditId = null; let dcContaObsEditId = null;
let dcDreObsEditId = null; let dcDreObsEditId = null;
// Mesma ideia, agora pra árvore da Análise Vertical (ContabilLinhaAnaliseVertical)
// — mesmo nível de indentação da DRE (linha.nivel), só que cada linha tem
// um valor+percentual por mês em vez de um valor único.
let dcAvColapsadas = new Set();
let dcAvObsEditId = null;
// Ids de ContabilAchado com o detalhe da conta usada no apontamento // Ids de ContabilAchado com o detalhe da conta usada no apontamento
// expandido (ver renderAchados) — só achados com `conta` preenchido (as // expandido (ver renderAchados) — só achados com `conta` preenchido (as
// regras gerais, ex. balanceamento Ativo x Passivo, não têm uma conta // regras gerais, ex. balanceamento Ativo x Passivo, não têm uma conta
@ -466,9 +501,37 @@ document.addEventListener("DOMContentLoaded", async () => {
dcDreObsEditId = null; dcDreObsEditId = null;
dcAchadosContaExpandida = new Set(); dcAchadosContaExpandida = new Set();
dcIndicadoresAtual = null; dcIndicadoresAtual = null;
// Aba "Análise Vertical" só existe quando o PDF tinha essa seção (nem
// todo relatório traz — ver ContabilApuracao.analise_vertical_meses) —
// some completamente quando vazia, em vez de mostrar uma tabela sem
// nenhuma coluna de mês.
const temAnaliseVertical = (apuracao.analise_vertical_meses || []).length > 0;
const avTabBtn = document.getElementById("dc-av-tab-btn");
const avEstavaAtiva = avTabBtn.classList.contains("is-active");
avTabBtn.hidden = !temAnaliseVertical;
if (!temAnaliseVertical && avEstavaAtiva) {
// A apuração anterior tinha Análise Vertical e estava nessa aba — como
// esta não tem, volta pra "Observações" pra não deixar o botão da aba
// ativa escondido com o painel dela ainda visível.
document.querySelectorAll("#dc-tabs .pa-tab").forEach((t) => t.classList.toggle("is-active", t.dataset.dcTab === "achados"));
document.querySelectorAll("[data-dc-panel]").forEach((panel) => {
panel.hidden = panel.getAttribute("data-dc-panel") !== "achados";
});
}
dcAvColapsadas = dcColapsoPadrao(apuracao.linhas_analise_vertical, (linha) => Math.max(0, linha.nivel));
dcAvDestaque = dcUltimaLevaVisivel(
apuracao.linhas_analise_vertical,
(linha) => Math.max(0, linha.nivel),
dcAvColapsadas
);
dcAvDestaqueHistorico = [];
dcAvObsEditId = null;
renderAchados(); renderAchados();
renderContas(); renderContas();
renderDre(); renderDre();
renderAnaliseVertical();
renderResumoFechamento(); renderResumoFechamento();
} }
@ -788,8 +851,10 @@ document.addEventListener("DOMContentLoaded", async () => {
// que Balancete e DRE são independentes. // que Balancete e DRE são independentes.
let dcContasDestaque = new Set(); let dcContasDestaque = new Set();
let dcDreDestaque = new Set(); let dcDreDestaque = new Set();
let dcAvDestaque = new Set();
let dcContasDestaqueHistorico = []; let dcContasDestaqueHistorico = [];
let dcDreDestaqueHistorico = []; let dcDreDestaqueHistorico = [];
let dcAvDestaqueHistorico = [];
// Filhos DIRETOS de um item (mesmo próximo nível, não os netos) — usado // Filhos DIRETOS de um item (mesmo próximo nível, não os netos) — usado
// pra saber quais linhas acabaram de ficar visíveis ao expandir `id`. // pra saber quais linhas acabaram de ficar visíveis ao expandir `id`.
@ -977,6 +1042,59 @@ document.addEventListener("DOMContentLoaded", async () => {
renderDre(); renderDre();
} }
// Mesma lógica de dcClicarValidadoConta()/dcClicarValidadoLinha(), sobre
// ContabilLinhaAnaliseVertical.
async function dcClicarValidadoAv(id) {
const linhas = apuracaoAtual.linhas_analise_vertical;
const nivelFn = (l) => Math.max(0, l.nivel);
const idx = linhas.findIndex((l) => l.id === id);
if (idx === -1) return;
const niveis = linhas.map(nivelFn);
const temFilhosItem = idx + 1 < linhas.length && niveis[idx + 1] > niveis[idx];
const linha = linhas[idx];
if (!temFilhosItem) {
const atualizada = await pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, !linha.validado);
linhas[idx] = atualizada;
renderAnaliseVertical();
return;
}
const descendentes = dcDescendentes(linhas, nivelFn, id);
const estado = dcEstadoValidacaoGrupo(linha, descendentes);
if (estado === "completo") {
const alvos = [linha, ...descendentes].filter((item) => item.validado);
const respostas = await Promise.all(
alvos.map((item) => pidAtualizarValidadoLinhaAnaliseVerticalContabil(item.id, false))
);
respostas.forEach((atualizada) => {
const i = linhas.findIndex((l) => l.id === atualizada.id);
if (i >= 0) linhas[i] = atualizada;
});
renderAnaliseVertical();
return;
}
if (estado === "parcial") {
if (!(await pidConfirm("Deseja validar todas as contas deste grupo?"))) return;
const alvos = [linha, ...descendentes].filter((item) => !item.validado);
const respostas = await Promise.all(
alvos.map((item) => pidAtualizarValidadoLinhaAnaliseVerticalContabil(item.id, true))
);
respostas.forEach((atualizada) => {
const i = linhas.findIndex((l) => l.id === atualizada.id);
if (i >= 0) linhas[i] = atualizada;
});
renderAnaliseVertical();
return;
}
const atualizada = await pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, true);
linhas[idx] = atualizada;
renderAnaliseVertical();
}
// Conteúdo do botão de observação de uma conta/linha — pedido explícito // Conteúdo do botão de observação de uma conta/linha — pedido explícito
// do usuário: a coluna "Observação" ficava poluída visualmente com // do usuário: a coluna "Observação" ficava poluída visualmente com
// "+ Observação"/o texto da observação em si repetido em toda linha; // "+ Observação"/o texto da observação em si repetido em toda linha;
@ -1313,6 +1431,188 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
}); });
// Cabeçalho de coluna da Análise Vertical é montado em JS (não hardcoded
// no HTML) porque o número de meses varia conforme o período do PDF
// (normalmente 3, ver ContabilApuracao.analise_vertical_meses) — cada mês
// vira duas colunas (Valor/Variação).
function renderAnaliseVerticalHead() {
const meses = apuracaoAtual.analise_vertical_meses || [];
const row = document.getElementById("dc-av-head-row");
row.innerHTML = `
<th>Descrição</th>
${meses
.map((mes) => {
const label = pidDcEscapeHtml(pidDcFormatMesAnaliseVertical(mes));
return `<th>${label} — Valor</th><th>${label} — Variação</th>`;
})
.join("")}
<th>Observação</th>
`;
}
// Mesma árvore recolhível do Balancete/DRE (ver renderContas/renderDre),
// agora sobre ContabilLinhaAnaliseVertical — cada linha mostra um
// valor+percentual por mês (colunas dinâmicas) em vez de um valor único.
function renderAnaliseVertical() {
renderAnaliseVerticalHead();
const body = document.getElementById("dc-av-body");
body.innerHTML = "";
const concluida = apuracaoAtual.status === "concluida";
const linhas = apuracaoAtual.linhas_analise_vertical;
const colspan = 2 + (apuracaoAtual.analise_vertical_meses || []).length * 2;
const nivelFn = (l) => Math.max(0, l.nivel);
const niveis = linhas.map(nivelFn);
const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]);
const pilhaColapso = [];
linhas.forEach((linha, i) => {
const nivel = niveis[i];
while (pilhaColapso.length && pilhaColapso[pilhaColapso.length - 1] >= nivel) {
pilhaColapso.pop();
}
const oculta = pilhaColapso.length > 0;
if (temFilhos[i] && dcAvColapsadas.has(linha.id)) {
pilhaColapso.push(nivel);
}
if (oculta) return;
const expandida = !dcAvColapsadas.has(linha.id);
const toggle = temFilhos[i]
? `<button type="button" class="dc-conta-toggle${expandida ? " is-expanded" : ""}" data-dc-av-toggle="${linha.id}" aria-label="${expandida ? "Recolher" : "Expandir"} ${pidDcEscapeHtml(linha.descricao)}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`
: `<span class="dc-conta-toggle-spacer"></span>`;
const validadoInfo = dcValidadoInfo(linha, linhas, nivelFn, temFilhos[i]);
const colunasMensais = linha.valores
.map(
(v) =>
`<td>${pidDcFormatMoeda(v.valor)}</td><td>${pidDcFormatPercentualAnaliseVertical(v.percentual)}</td>`
)
.join("");
const tr = document.createElement("tr");
tr.className = linha.totalizador ? "dc-dre-row dc-dre-row--totalizador" : "dc-dre-row";
if (dcAvDestaque.has(linha.id)) tr.classList.add("dc-conta-row--destaque");
const indentacao = nivel * 16;
tr.innerHTML = `
<td style="padding-left: calc(var(--space-4) + ${indentacao}px)">
<span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(linha.descricao)}</span></span>
</td>
${colunasMensais}
<td>
<div class="dc-obs-cell-actions">
<button type="button" class="dc-conta-validado-btn${validadoInfo.classe}" data-dc-av-validado="${linha.id}" title="${validadoInfo.title}" aria-label="${validadoInfo.label}" aria-pressed="${validadoInfo.pressed}" ${concluida ? "disabled" : ""}>
${PID_DC_VALIDADO_ICONE}
</button>
<button type="button" class="dc-conta-observacao-btn${linha.observacao ? " dc-conta-observacao-btn--preenchida" : ""}" data-dc-av-observacao="${linha.id}" title="${pidDcEscapeHtml(linha.observacao || "Adicionar observação")}" aria-label="${linha.observacao ? "Editar observação" : "Adicionar observação"}">
${PID_DC_OBSERVACAO_ICONE}
</button>
</div>
</td>
`;
body.appendChild(tr);
if (dcAvObsEditId === linha.id) {
const editRow = document.createElement("tr");
editRow.className = "dc-obs-edit-row";
editRow.innerHTML = `
<td colspan="${colspan}">
<div class="dc-obs-edit">
<div class="modal-field">
<textarea id="dc-av-obs-texto-${linha.id}" rows="3" placeholder="Anote sua análise..." ${concluida ? "disabled" : ""}>${pidDcEscapeHtml(linha.observacao || "")}</textarea>
</div>
<label class="modal-checkbox">
<input type="checkbox" id="dc-av-obs-mostrar-${linha.id}" ${linha.observacao && !linha.oculta_no_relatorio ? "checked" : ""} ${concluida ? "disabled" : ""} />
Mostrar esta observação ao cliente no relatório
</label>
<p class="modal-error" id="dc-av-obs-error-${linha.id}"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" data-dc-av-obs-cancel="${linha.id}">${concluida ? "Fechar" : "Cancelar"}</button>
${concluida ? "" : `<button type="button" class="btn-solid" data-dc-av-obs-save="${linha.id}">Salvar</button>`}
</div>
</div>
</td>
`;
body.appendChild(editRow);
}
});
renderAnaliseVerticalObsResumo();
}
document.getElementById("dc-av-body").addEventListener("click", async (event) => {
const toggleBtn = event.target.closest("[data-dc-av-toggle]");
if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-av-toggle"));
const estavaColapsada = dcAvColapsadas.has(id);
if (estavaColapsada) dcAvColapsadas.delete(id);
else dcAvColapsadas.add(id);
const nivelFn = (linha) => Math.max(0, linha.nivel);
if (estavaColapsada) {
dcAvDestaqueHistorico.push(dcAvDestaque);
dcAvDestaque = dcFilhosDiretos(apuracaoAtual.linhas_analise_vertical, nivelFn, id);
} else {
dcAvDestaque = dcAvDestaqueHistorico.pop() || new Set();
}
renderAnaliseVertical();
return;
}
const validadoBtn = event.target.closest("[data-dc-av-validado]");
if (validadoBtn) {
if (validadoBtn.disabled) return;
const id = Number(validadoBtn.getAttribute("data-dc-av-validado"));
validadoBtn.disabled = true;
try {
await dcClicarValidadoAv(id);
} catch (e) {
await pidAlert(e.message);
} finally {
validadoBtn.disabled = false;
}
return;
}
const obsBtn = event.target.closest("[data-dc-av-observacao]");
if (obsBtn) {
const id = Number(obsBtn.getAttribute("data-dc-av-observacao"));
dcAvObsEditId = dcAvObsEditId === id ? null : id;
renderAnaliseVertical();
return;
}
const cancelBtn = event.target.closest("[data-dc-av-obs-cancel]");
if (cancelBtn) {
dcAvObsEditId = null;
renderAnaliseVertical();
return;
}
const saveBtn = event.target.closest("[data-dc-av-obs-save]");
if (saveBtn) {
const id = Number(saveBtn.getAttribute("data-dc-av-obs-save"));
const errorEl = document.getElementById(`dc-av-obs-error-${id}`);
errorEl.textContent = "";
const texto = document.getElementById(`dc-av-obs-texto-${id}`).value;
const mostrar = document.getElementById(`dc-av-obs-mostrar-${id}`).checked;
saveBtn.disabled = true;
try {
const atualizada = await pidAtualizarObservacaoLinhaAnaliseVerticalContabil(id, {
observacao: texto,
oculta_no_relatorio: !mostrar,
});
const indice = apuracaoAtual.linhas_analise_vertical.findIndex((l) => l.id === id);
apuracaoAtual.linhas_analise_vertical[indice] = atualizada;
dcAvObsEditId = null;
renderAnaliseVertical();
} catch (e) {
errorEl.textContent = e.message;
saveBtn.disabled = false;
}
}
});
// ---- Tabs ---- // ---- Tabs ----
document.getElementById("dc-tabs").addEventListener("click", async (event) => { document.getElementById("dc-tabs").addEventListener("click", async (event) => {
@ -2316,6 +2616,42 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
}); });
function renderAnaliseVerticalObsResumo() {
const lista = document.getElementById("dc-av-obs-list");
const empty = document.getElementById("dc-av-obs-empty");
const count = document.getElementById("dc-av-obs-count");
const concluida = apuracaoAtual.status === "concluida";
const itens = apuracaoAtual.linhas_analise_vertical.filter((linha) => linha.observacao);
count.textContent = String(itens.length);
empty.hidden = itens.length > 0;
lista.innerHTML = itens
.map((linha) =>
pidDcObsItemHtml(
{ id: linha.id, referencia: linha.descricao, texto: linha.observacao, oculto: linha.oculta_no_relatorio },
concluida,
"data-dc-av-resumo-toggle"
)
)
.join("");
}
document.getElementById("dc-av-obs-list").addEventListener("click", async (event) => {
const btn = event.target.closest("[data-dc-av-resumo-toggle]");
if (!btn || btn.disabled) return;
const id = Number(btn.getAttribute("data-dc-av-resumo-toggle"));
const linha = apuracaoAtual.linhas_analise_vertical.find((l) => l.id === id);
btn.disabled = true;
try {
const atualizada = await pidAlternarOcultoLinhaAnaliseVerticalContabil(id, !linha.oculta_no_relatorio);
const indice = apuracaoAtual.linhas_analise_vertical.findIndex((l) => l.id === id);
apuracaoAtual.linhas_analise_vertical[indice] = atualizada;
renderAnaliseVerticalObsResumo();
} catch (e) {
btn.disabled = false;
await pidAlert(e.message);
}
});
// ---- Modal de achado (tratar/ignorar) ---- // ---- Modal de achado (tratar/ignorar) ----
const achadoModal = document.getElementById("dc-achado-modal"); const achadoModal = document.getElementById("dc-achado-modal");

View File

@ -555,6 +555,9 @@
<span class="dcr-tabs__indicator" id="dcr-tabs-indicator"></span> <span class="dcr-tabs__indicator" id="dcr-tabs-indicator"></span>
<button type="button" class="dcr-tab is-active" data-dcr-tab="balancete">Balancete</button> <button type="button" class="dcr-tab is-active" data-dcr-tab="balancete">Balancete</button>
<button type="button" class="dcr-tab" data-dcr-tab="dre">D.R.E.</button> <button type="button" class="dcr-tab" data-dcr-tab="dre">D.R.E.</button>
{% if analise_vertical_meses %}
<button type="button" class="dcr-tab" data-dcr-tab="analise-vertical">Análise Vertical</button>
{% endif %}
<button type="button" class="dcr-tab" data-dcr-tab="indicadores">Resumo</button> <button type="button" class="dcr-tab" data-dcr-tab="indicadores">Resumo</button>
</nav> </nav>
@ -724,6 +727,92 @@
</div> </div>
</div> </div>
{% if analise_vertical_meses %}
<div class="dcr-tab-panel" data-dcr-panel="analise-vertical" hidden>
<div class="dcr-secao">
<h2>
<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="M3 3v18h18"/><path d="M18 17V9M13 17V5M8 17v-3"/></svg>
Demonstração Mensal (Análise Vertical)
</h2>
<div class="dcr-tabela-wrap">
<table class="dcr-tabela">
<thead>
<tr>
<th>Descrição</th>
{% for mes in analise_vertical_meses %}
<th class="dcr-valor">{{ mes|capfirst }} — Valor</th>
<th class="dcr-valor">{{ mes|capfirst }} — Variação</th>
{% endfor %}
<th>Observação</th>
</tr>
</thead>
<tbody id="dcr-av-body">
{% for item in linhas_analise_vertical %}
<tr{% if item.linha.totalizador %} class="dcr-linha-total"{% endif %} data-dcr-nivel="{{ item.nivel }}" data-dcr-id="av-{{ item.linha.id }}" data-dcr-tem-filhos="{% if item.tem_filhos %}1{% else %}0{% endif %}" data-dcr-colapsado-padrao="{% if item.colapsado_padrao %}1{% else %}0{% endif %}">
<td style="padding-left: {{ item.nivel_px|add:12 }}px">
<span class="dcr-desc-cell">
{% if item.tem_filhos %}
<button type="button" class="dcr-toggle{% if not item.colapsado_padrao %} is-expanded{% endif %}" data-dcr-toggle aria-label="Recolher/expandir">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
{% else %}
<span class="dcr-toggle-spacer"></span>
{% endif %}
<span>{{ item.linha.descricao }}</span>
</span>
</td>
{% for v in item.linha.valores %}
<td class="dcr-valor">{{ v.valor|moeda_av }}</td>
<td class="dcr-valor">{{ v.percentual|percentual_av }}</td>
{% endfor %}
<td>
{% if item.linha.observacao and not item.linha.oculta_no_relatorio %}
<button type="button" class="dcr-obs-btn" data-dcr-obs-toggle="av-{{ item.linha.id }}" aria-label="Ver observação">
<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>
</button>
{% endif %}
</td>
</tr>
{% if item.linha.observacao and not item.linha.oculta_no_relatorio %}
<tr class="dcr-obs-inline-row" data-dcr-obs-row="av-{{ item.linha.id }}" hidden>
<td colspan="{{ analise_vertical_meses|length|add:2 }}">
<div class="dcr-obs-inline">
<span class="dcr-obs-inline__rotulo">Observação do contador</span>
<p class="dcr-obs-inline__texto">{{ item.linha.observacao }}</p>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="dcr-secao">
<h2>
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>
Observações da Análise Vertical
</h2>
{% if observacoes_analise_vertical %}
<ul class="dcr-obs-lista">
{% for linha in observacoes_analise_vertical %}
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
<div class="dcr-obs-item__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="M3 3v18h18"/><path d="M18 17V9M13 17V5M8 17v-3"/></svg></div>
<div>
<div class="dcr-obs-item__titulo">{{ linha.descricao }}</div>
<div class="dcr-obs-item__texto">{{ linha.observacao }}</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="dcr-vazio">Nenhuma observação registrada na Análise Vertical.</p>
{% endif %}
</div>
</div>
{% endif %}
<div class="dcr-tab-panel" data-dcr-panel="indicadores" hidden> <div class="dcr-tab-panel" data-dcr-panel="indicadores" hidden>
{% if apuracao.resumo_fechamento %} {% if apuracao.resumo_fechamento %}
<div class="dcr-secao dcr-resumo-fechamento"> <div class="dcr-secao dcr-resumo-fechamento">
@ -788,7 +877,7 @@
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg> <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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>
Todas as Observações da Análise Todas as Observações da Análise
</h2> </h2>
{% if observacoes_contas or observacoes_dre or achados_com_observacao %} {% if observacoes_contas or observacoes_dre or observacoes_analise_vertical or achados_com_observacao %}
<ul class="dcr-obs-lista"> <ul class="dcr-obs-lista">
{% for conta in observacoes_contas %} {% for conta in observacoes_contas %}
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}"> <li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
@ -808,6 +897,15 @@
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
{% for linha in observacoes_analise_vertical %}
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
<div class="dcr-obs-item__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="M3 3v18h18"/><path d="M18 17V9M13 17V5M8 17v-3"/></svg></div>
<div>
<div class="dcr-obs-item__titulo">Análise Vertical — {{ linha.descricao }}</div>
<div class="dcr-obs-item__texto">{{ linha.observacao }}</div>
</div>
</li>
{% endfor %}
{% for achado in achados_com_observacao %} {% for achado in achados_com_observacao %}
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}"> <li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
<div class="dcr-obs-item__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></div> <div class="dcr-obs-item__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></div>
@ -1061,6 +1159,8 @@
pidDcrObs("dcr-balancete-body"); pidDcrObs("dcr-balancete-body");
pidDcrArvore("dcr-dre-body"); pidDcrArvore("dcr-dre-body");
pidDcrObs("dcr-dre-body"); pidDcrObs("dcr-dre-body");
pidDcrArvore("dcr-av-body");
pidDcrObs("dcr-av-body");
// ---- Cards: contagem animada + cor por sinal/liquidez ---- // ---- Cards: contagem animada + cor por sinal/liquidez ----
// O texto final exibido é sempre `data-final` (a string já formatada // O texto final exibido é sempre `data-final` (a string já formatada

View File

@ -406,6 +406,7 @@
<button type="button" class="pa-tab is-active" data-dc-tab="achados">Observações</button> <button type="button" class="pa-tab is-active" data-dc-tab="achados">Observações</button>
<button type="button" class="pa-tab" data-dc-tab="balancete">Balancete</button> <button type="button" class="pa-tab" data-dc-tab="balancete">Balancete</button>
<button type="button" class="pa-tab" data-dc-tab="dre">DRE</button> <button type="button" class="pa-tab" data-dc-tab="dre">DRE</button>
<button type="button" class="pa-tab" data-dc-tab="analise-vertical" id="dc-av-tab-btn" hidden>Análise Vertical</button>
<button type="button" class="pa-tab" data-dc-tab="dashboard">Dashboard</button> <button type="button" class="pa-tab" data-dc-tab="dashboard">Dashboard</button>
</div> </div>
@ -488,6 +489,24 @@
</section> </section>
</div> </div>
<div class="pa-tab-panel" data-dc-panel="analise-vertical" hidden>
<div class="pa-table-wrap">
<table class="pa-table dc-dre-table dc-av-table">
<thead>
<tr id="dc-av-head-row">
<th>Descrição</th>
</tr>
</thead>
<tbody id="dc-av-body"></tbody>
</table>
</div>
<section class="dc-obs-resumo">
<h3 class="dc-obs-resumo__titulo">Observações da Análise Vertical <span class="dc-obs-resumo__count" id="dc-av-obs-count">0</span></h3>
<div class="dc-dash-obs-list" id="dc-av-obs-list"></div>
<p class="dc-empty" id="dc-av-obs-empty" hidden>Nenhuma observação registrada na Análise Vertical ainda.</p>
</section>
</div>
<div class="pa-tab-panel" data-dc-panel="dashboard" hidden> <div class="pa-tab-panel" data-dc-panel="dashboard" hidden>
<p class="dc-dash-intro"> <p class="dc-dash-intro">
Pré-visualização do que vai para o relatório gerado pelo botão "Gerar Dashboard". Use o botão de olho pra ocultar um card de indicador ou uma observação — o que estiver oculto aqui não aparece no relatório. Pré-visualização do que vai para o relatório gerado pelo botão "Gerar Dashboard". Use o botão de olho pra ocultar um card de indicador ou uma observação — o que estiver oculto aqui não aparece no relatório.