Inclusão da Análise Vertical
This commit is contained in:
parent
617d3afa32
commit
b0b63d90a5
@ -9,6 +9,7 @@ from .models import (
|
||||
ContabilAchado,
|
||||
ContabilApuracao,
|
||||
ContabilConta,
|
||||
ContabilLinhaAnaliseVertical,
|
||||
ContabilLinhaDre,
|
||||
Departamento,
|
||||
EmpresaQuestor,
|
||||
@ -265,6 +266,12 @@ class ContabilLinhaDreAdmin(admin.ModelAdmin):
|
||||
search_fields = ("descricao",)
|
||||
|
||||
|
||||
@admin.register(ContabilLinhaAnaliseVertical)
|
||||
class ContabilLinhaAnaliseVerticalAdmin(admin.ModelAdmin):
|
||||
list_display = ("apuracao", "ordem", "descricao", "totalizador")
|
||||
search_fields = ("descricao",)
|
||||
|
||||
|
||||
@admin.register(ContabilAchado)
|
||||
class ContabilAchadoAdmin(admin.ModelAdmin):
|
||||
list_display = ("apuracao", "regra", "severidade", "status", "titulo")
|
||||
|
||||
@ -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)
|
||||
|
||||
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.
|
||||
|
||||
@ -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.
|
||||
- **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.
|
||||
- `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`).
|
||||
- **`ContabilConta`**: uma linha do Balancete. `observacao` (`TextField`, editável via PATCH em **qualquer** conta, tenha ela gerado achado ou não) — é o espaço de "análise" pedido pelo usuário, independente da auditoria automática. `oculta_no_relatorio` (default `True`, invertido numa rodada — ver "Editor de observação inline" abaixo) e `validado` (`BooleanField`, default `False` — checkbox informativo de "já conferi esta conta", sem efeito em achado/observação/relatório).
|
||||
- **`ContabilLinhaDre`**: uma linha da DRE, sem código de classificação (o relatório não traz um pra DRE, diferente do Balancete). Mesmos `oculta_no_relatorio`/`validado` de `ContabilConta`.
|
||||
- **`ContabilLinhaAnaliseVertical`** (rodada 123): uma linha da Demonstração Mensal (Análise Vertical) — mesma árvore/descrição/nível da DRE, mas `valores` (`JSONField`) guarda um `{"valor": "...", "percentual": "..."}` por mês em vez de um `DecimalField` único (gravado como texto, não float, pra não perder precisão), alinhado por posição com `ContabilApuracao.analise_vertical_meses` (ex.: `["mai/2026", "jun/2026", "jul/2026"]`, lista compartilhada por toda a apuração, não por linha). Mesmos `observacao`/`oculta_no_relatorio`/`validado` de `ContabilConta`/`ContabilLinhaDre` — recursos por linha idênticos (editor inline, tri-state, ocultar do relatório), decisão confirmada com o usuário via `AskUserQuestion` antes de implementar. Lista vazia (`analise_vertical_meses=[]`, nenhuma linha) quando o PDF não tinha essa seção — relatório antigo, ou empresa sem essa seção habilitada no Questor; a aba/tab correspondente some nesse caso (ver "Análise Vertical" abaixo).
|
||||
- **`ContabilAchado`**: achado de auditoria, nasce automático em `create()`, nunca é apagado — só muda de `status` (`pendente`/`tratado`/`ignorado`), sempre com `observacao_contador` obrigatória ao mudar de pendente (mesmo espírito de "histórico completo preservado" de `ImportacaoPlanoSaudeAuditoria`). `conta` é nullable — achados 1 e 2 (balanceamento/débito-crédito) são gerais, sem uma conta específica.
|
||||
|
||||
## `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.
|
||||
|
||||
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.
|
||||
|
||||
@ -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*`).
|
||||
|
||||
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).
|
||||
|
||||
## 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).
|
||||
- `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-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação).
|
||||
|
||||
@ -40,11 +40,33 @@ class LinhaDreExtraida:
|
||||
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
|
||||
class ResultadoExtracao:
|
||||
cabecalho: CabecalhoExtraido
|
||||
contas: list[LinhaBalanceteExtraida]
|
||||
linhas_dre: list[LinhaDreExtraida]
|
||||
meses_analise_vertical: list[str] = field(default_factory=list)
|
||||
linhas_analise_vertical: list[LinhaAnaliseVerticalExtraida] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@ -24,7 +24,14 @@ from typing import IO
|
||||
|
||||
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
|
||||
# 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_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
|
||||
# uma linha específica, ex. razão custo/receita) — além do negrito (que já
|
||||
# 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
|
||||
|
||||
|
||||
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:
|
||||
dia, mes, ano = texto.split("/")
|
||||
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
|
||||
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
|
||||
presentes, são ignoradas de propósito — o Dashboard Contábil calcula
|
||||
variação mês a mês a partir do próprio histórico de apurações já
|
||||
processadas no Portal, não a partir desse relatório complementar (ver
|
||||
CLAUDE.md/plano.md)."""
|
||||
presentes, também são extraídas (`linhas_analise_vertical`/
|
||||
`meses_analise_vertical`) — é a mesma árvore da DRE, só que com um
|
||||
valor+percentual por mês em vez de um valor único; **não** substitui o
|
||||
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
|
||||
contas: list[LinhaBalanceteExtraida] = []
|
||||
linhas_dre: list[LinhaDreExtraida] = []
|
||||
linhas_analise_vertical: list[LinhaAnaliseVerticalExtraida] = []
|
||||
meses_analise_vertical: list[str] | None = None
|
||||
|
||||
secao = "cabecalho"
|
||||
ordem_dre = 0
|
||||
ordem_av = 0
|
||||
base_x0_dre: float | None = None
|
||||
base_x0_av: float | None = None
|
||||
|
||||
with pdfplumber.open(origem) as pdf:
|
||||
if not pdf.pages:
|
||||
@ -185,9 +222,17 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
|
||||
continue
|
||||
|
||||
if texto.startswith(_TITULO_ANALISE_VERTICAL):
|
||||
secao = "ignorar"
|
||||
break
|
||||
if secao != "dre" and texto.startswith(_TITULO_DRE):
|
||||
if secao != "analise_vertical":
|
||||
secao = "analise_vertical"
|
||||
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"
|
||||
base_x0_dre = None
|
||||
continue
|
||||
@ -243,12 +288,59 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
|
||||
totalizador=linha["negrito"],
|
||||
)
|
||||
)
|
||||
if secao == "ignorar":
|
||||
break
|
||||
elif secao == "analise_vertical":
|
||||
# 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:
|
||||
raise ExtracaoInvalidaError("Nenhuma linha de balancete encontrada no PDF.")
|
||||
if not linhas_dre:
|
||||
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,
|
||||
)
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -2013,6 +2013,13 @@ class ContabilApuracao(models.Model):
|
||||
resumo_fechamento = models.TextField(
|
||||
"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:
|
||||
verbose_name = "Apuração do Relatório Contábil"
|
||||
@ -2101,6 +2108,36 @@ class ContabilLinhaDre(models.Model):
|
||||
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):
|
||||
"""Um achado gerado automaticamente pelo motor de regras
|
||||
(`dashboard_contabil.regras`) ao criar a apuração — nunca é apagado, só
|
||||
|
||||
@ -19,6 +19,7 @@ from .models import (
|
||||
ContabilAchado,
|
||||
ContabilApuracao,
|
||||
ContabilConta,
|
||||
ContabilLinhaAnaliseVertical,
|
||||
ContabilLinhaDre,
|
||||
Departamento,
|
||||
EmpresaQuestor,
|
||||
@ -2259,6 +2260,13 @@ class ContabilLinhaDreSerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
conta_codigo = serializers.CharField(source="conta.codigo", 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)
|
||||
contas = ContabilContaSerializer(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)
|
||||
|
||||
class Meta:
|
||||
@ -2560,5 +2569,7 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
|
||||
"resumo_fechamento",
|
||||
"contas",
|
||||
"linhas_dre",
|
||||
"analise_vertical_meses",
|
||||
"linhas_analise_vertical",
|
||||
"achados",
|
||||
]
|
||||
|
||||
@ -47,6 +47,27 @@ def indice(valor: Decimal | None) -> str:
|
||||
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
|
||||
def competencia(valor: date | None) -> str:
|
||||
return valor.strftime("%m/%Y") if valor else "—"
|
||||
|
||||
@ -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-contas", views.ContabilContaViewSet, basename="contabil-conta")
|
||||
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-indicadores-definicoes",
|
||||
|
||||
@ -54,6 +54,7 @@ from .models import (
|
||||
ContabilAchado,
|
||||
ContabilApuracao,
|
||||
ContabilConta,
|
||||
ContabilLinhaAnaliseVertical,
|
||||
ContabilLinhaDre,
|
||||
Departamento,
|
||||
Favorito,
|
||||
@ -132,6 +133,7 @@ from .serializers import (
|
||||
ContabilIndicadoresOcultosSerializer,
|
||||
ContabilIndicadoresSelecionadosSerializer,
|
||||
ContabilResumoFechamentoSerializer,
|
||||
ContabilLinhaAnaliseVerticalSerializer,
|
||||
ContabilLinhaDreSerializer,
|
||||
DepartamentoSerializer,
|
||||
FavoritoSerializer,
|
||||
@ -3729,7 +3731,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
def get_queryset(self) -> QuerySet[ContabilApuracao]:
|
||||
queryset = super().get_queryset()
|
||||
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
|
||||
|
||||
def get_serializer_class(self) -> type[ModelSerializer]:
|
||||
@ -3782,6 +3786,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
periodo_fim=cabecalho.periodo_fim,
|
||||
arquivo=ContentFile(conteudo, name=arquivo.name),
|
||||
criado_por=request.user,
|
||||
analise_vertical_meses=resultado.extracao.meses_analise_vertical,
|
||||
)
|
||||
ContabilConta.objects.bulk_create(
|
||||
[
|
||||
@ -3813,6 +3818,22 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
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()}
|
||||
ContabilAchado.objects.bulk_create(
|
||||
[
|
||||
@ -3865,6 +3886,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
apuracao = self.get_object()
|
||||
contas = list(apuracao.contas.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"))
|
||||
|
||||
dados = _contabil_coleta_dados_indicadores(apuracao)
|
||||
@ -3894,8 +3916,15 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
"evolucao": evolucao,
|
||||
"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),
|
||||
"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_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": [
|
||||
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()
|
||||
|
||||
|
||||
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):
|
||||
"""Achados de auditoria gerados automaticamente na criação da apuração —
|
||||
GET/PATCH (marcar como tratado/ignorado, sempre com justificativa) +
|
||||
|
||||
@ -560,6 +560,16 @@
|
||||
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) {
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
|
||||
@ -117,6 +117,21 @@ const PID_DC_ICON_EDITAR =
|
||||
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>';
|
||||
|
||||
// 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) {
|
||||
if (!iso) return "";
|
||||
const [ano, mes] = iso.split("-");
|
||||
@ -188,6 +203,14 @@ async function pidAtualizarValidadoLinhaDreContabil(id, 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) {
|
||||
return pidApiRequest(`/contabil-achados/${id}/`, {
|
||||
method: "PATCH",
|
||||
@ -216,6 +239,13 @@ async function pidAlternarOcultoLinhaDreContabil(id, 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) {
|
||||
return pidApiRequest(`/contabil-achados/${id}/alternar-oculto/`, {
|
||||
method: "POST",
|
||||
@ -305,6 +335,11 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// fora do popup (pedido explícito do usuário). null = nenhum editor aberto.
|
||||
let dcContaObsEditId = 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
|
||||
// expandido (ver renderAchados) — só achados com `conta` preenchido (as
|
||||
// regras gerais, ex. balanceamento Ativo x Passivo, não têm uma conta
|
||||
@ -466,9 +501,37 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
dcDreObsEditId = null;
|
||||
dcAchadosContaExpandida = new Set();
|
||||
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();
|
||||
renderContas();
|
||||
renderDre();
|
||||
renderAnaliseVertical();
|
||||
renderResumoFechamento();
|
||||
}
|
||||
|
||||
@ -788,8 +851,10 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// que Balancete e DRE são independentes.
|
||||
let dcContasDestaque = new Set();
|
||||
let dcDreDestaque = new Set();
|
||||
let dcAvDestaque = new Set();
|
||||
let dcContasDestaqueHistorico = [];
|
||||
let dcDreDestaqueHistorico = [];
|
||||
let dcAvDestaqueHistorico = [];
|
||||
|
||||
// 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`.
|
||||
@ -977,6 +1042,59 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
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
|
||||
// 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;
|
||||
@ -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 ----
|
||||
|
||||
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) ----
|
||||
|
||||
const achadoModal = document.getElementById("dc-achado-modal");
|
||||
|
||||
@ -555,6 +555,9 @@
|
||||
<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" 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>
|
||||
</nav>
|
||||
|
||||
@ -724,6 +727,92 @@
|
||||
</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>
|
||||
{% if apuracao.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>
|
||||
Todas as Observações da Análise
|
||||
</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">
|
||||
{% for conta in observacoes_contas %}
|
||||
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
|
||||
@ -808,6 +897,15 @@
|
||||
</div>
|
||||
</li>
|
||||
{% 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 %}
|
||||
<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>
|
||||
@ -1061,6 +1159,8 @@
|
||||
pidDcrObs("dcr-balancete-body");
|
||||
pidDcrArvore("dcr-dre-body");
|
||||
pidDcrObs("dcr-dre-body");
|
||||
pidDcrArvore("dcr-av-body");
|
||||
pidDcrObs("dcr-av-body");
|
||||
|
||||
// ---- Cards: contagem animada + cor por sinal/liquidez ----
|
||||
// O texto final exibido é sempre `data-final` (a string já formatada
|
||||
|
||||
@ -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" 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="analise-vertical" id="dc-av-tab-btn" hidden>Análise Vertical</button>
|
||||
<button type="button" class="pa-tab" data-dc-tab="dashboard">Dashboard</button>
|
||||
</div>
|
||||
|
||||
@ -488,6 +489,24 @@
|
||||
</section>
|
||||
</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>
|
||||
<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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user