Atualização aplicação Relatório Contábil

This commit is contained in:
Gabriel 2026-09-14 11:08:24 -03:00
parent 66e83090b6
commit 03af149104
31 changed files with 1164 additions and 419 deletions

View File

@ -468,4 +468,8 @@ Usuário reportou "Erro ao processar a solicitação." ao reprocessar os mesmos
### 92. Dashboard Contábil (Relatórios > Contabilidade) — nova aplicação
Nova aplicação em Relatórios > Contabilidade (nasce restrita ao perfil "Inovação", mesmo padrão de "Não Conformidades"): o contador anexa o PDF de Balancete + DRE (modelo Questor), a ferramenta extrai as contas/linhas e roda um motor de regras de auditoria, com revisão de achados/observações. Subgrupo "Contabilidade" novo em `catalogo.py` dentro de `relatorios`. Histórico técnico completo (fórmulas, models, desafio de extração do PDF) e das rodadas seguintes (93+, incluindo o botão "Gerar Dashboard") em `portal_api/dashboard_contabil/CHANGELOG.md`/`CLAUDE.md` — a partir daqui, rodadas específicas desta aplicação não duplicam entrada aqui (ver nota no topo deste arquivo).
Nova aplicação em Relatórios > Contabilidade (nasce restrita ao perfil "Inovação", mesmo padrão de "Não Conformidades"): o contador anexa o PDF de Balancete + DRE (modelo Questor), a ferramenta extrai as contas/linhas e roda um motor de regras de auditoria, com revisão de achados/observações. Subgrupo "Contabilidade" novo em `catalogo.py` dentro de `relatorios`. Histórico técnico completo (fórmulas, models, desafio de extração do PDF) e das rodadas seguintes (94+, incluindo o botão "Gerar Dashboard") em `portal_api/dashboard_contabil/CHANGELOG.md`/`CLAUDE.md` — a partir daqui, rodadas específicas desta aplicação não duplicam entrada aqui (ver nota no topo deste arquivo).
### 93. Menu "Relatórios" — remoção do placeholder "Relatório Setorial"
Pedido (2026-09-14): remover o item "Relatório Setorial" (`href="#"`, sem tela própria) do menu Relatórios, já que o tópico agora tem itens de verdade ("Qualidade" → Não Conformidades, "Contabilidade" → Relatório Contábil). Removido de `MODULE_APPS["relatorios"]` em `catalogo.py` e do `<li data-app="relatorio-setorial">` hardcoded nas 13 páginas-shell (o menu é replicado por template, não um componente único — ver "Ordem de `<script>`" no `CLAUDE.md` raiz pro mesmo padrão de duplicação entre shells). O item "Indicadores" (outro placeholder `href="#"` dentro de Relatórios) não foi tocado, não fazia parte do pedido. Perfis já existentes no banco podem manter uma chave órfã `"relatorio-setorial"` dentro de `permissoes["relatorios"]["apps"]` — inofensiva, mesmo padrão já visto quando "Gerar Contrato"/"Gerar Procuração" foram removidos de Geradoc (ver comentário em `catalogo.py`).

View File

@ -12,6 +12,7 @@ from .models import (
ContabilLinhaAnaliseVertical,
ContabilLinhaDre,
ContabilObservacao,
ContabilObservacaoEdicao,
Departamento,
EmpresaQuestor,
Favorito,
@ -289,6 +290,12 @@ class ContabilObservacaoAdmin(admin.ModelAdmin):
ordering = ("-competencia_origem", "codigo_empresa")
@admin.register(ContabilObservacaoEdicao)
class ContabilObservacaoEdicaoAdmin(admin.ModelAdmin):
list_display = ("observacao", "editado_por", "editado_em")
ordering = ("-editado_em",)
@admin.register(ContabilAchado)
class ContabilAchadoAdmin(admin.ModelAdmin):
list_display = ("apuracao", "regra", "severidade", "status", "titulo")

View File

@ -42,14 +42,12 @@ MODULE_APPS = {
{"key": "indicador-desempenho", "label": "Indicador de Desempenho"},
],
"relatorios": [
{"key": "relatorio-setorial", "label": "Relatório Setorial"},
# Subgrupo com uma única tool (toggle único, sem par visualizar/editar
# — mesmo formato já usado em Auditorias, ex. "Consultoria Tributária"
# → "Controle Simples Nacional") pra a ferramenta "Não Conformidades"
# ficar em Relatórios > Qualidade no menu. Restrita por padrão: ver o
# override em seed_portal.py (só "Integração e Inovação" nasce com
# acesso, diferente de "relatorio-setorial" que herda automático de
# SECTORAL_KEYS).
# acesso — diferente do resto de SECTORAL_KEYS, que herda automático).
{
"key": "qualidade",
"label": "Qualidade",

View File

@ -240,3 +240,41 @@ Model novo `ContabilObservacao` (migração `0071`, que também copia as observa
Na tela, o editor inline virou uma thread: o histórico da conta em cima (cada item com autor, data, competência de origem, selos de "Histórico"/"Encerrada"/"Aparece ao cliente" e ações de olho, editar, excluir e encerrar) e o campo de observação nova embaixo; o botão da coluna "Observação" ganhou um contador. Os resumos por aba e a lista consolidada da aba "Dashboard" passaram a listar observações vigentes com assinatura, e ganharam os chips "Todas / Visíveis ao cliente / Internas". No relatório "Gerar Dashboard", cada observação visível aparece assinada e, quando vem de um mês anterior, com a competência em que foi registrada.
Testado ponta a ponta via `Client.force_login()` dentro de uma transação com rollback (listagem por vigência, criação com chave derivada no servidor, alvo de outra apuração recusado, edição, bloqueio do texto quando a origem está concluída, visibilidade ainda alternável nesse caso, encerrar/reativar com as fronteiras de competência, herança numa competência seguinte e o relatório nos dois meses) — nada gravado em produção além da migração em si. Detalhe completo no `CLAUDE.md` desta pasta.
### 127. Botão de excluir observação removido da thread
Pedido explícito do usuário, ainda na mesma funcionalidade da rodada 126: a exclusão de uma observação pela tela ficou limitada ao botão de "ocultar das próximas competências" (`encerrar`) — o botão de lixeira ao lado de cada item da thread (`dcObsItemHtml()`) e seu handler (`dcTrataCliqueObservacao()`) foram removidos, junto da função `pidExcluirObservacaoContabil()` que ficou sem consumidor. O `DELETE /api/contabil-observacoes/{id}/` do backend não foi alterado (continua sujeito à mesma regra de imutabilidade de sempre), só deixou de ser chamado pelo frontend.
### 128. Selo "Editada" + histórico de edições de uma observação
Pedido explícito do usuário, complementando a rodada 127: sem a opção de excluir, uma edição de texto por cima do que já estava escrito virou uma forma indireta de "apagar" uma observação importante sem deixar rastro — "evitamos a ocultação de observações importantes". Model novo `ContabilObservacaoEdicao` (migração `0072`, FK `CASCADE` pra `ContabilObservacao`) grava `texto_anterior`/`texto_novo`/`editado_por`/`editado_em` toda vez que um `PATCH` muda o texto de fato (`ContabilObservacaoViewSet.partial_update()`, comparando antes de salvar — reenviar o mesmo texto ou só alternar `mostrar_ao_cliente`/`encerrar`/`reativar` não geram registro). `ContabilObservacaoSerializer` ganhou `editada` (booleano) e `edicoes` (histórico completo, aninhado) — só usados pela tela, o relatório HTML pro cliente nunca os expõe.
Na tela, cada observação editada ganha o selo "Editada" ao lado dos demais ("Histórico"/"Encerrada"/"Aparece ao cliente"/"Interna") e um botão de relógio que abre um modal listando texto anterior (riscado) → texto novo, autor e data de cada edição, mais recente primeiro — usa o `edicoes` já carregado junto da observação, sem chamada de API própria. Validado ponta a ponta com `Client.force_login()` em transação com rollback forçado (detalhe completo no `CLAUDE.md` desta pasta).
### 129. Motor de regras de auditoria: 2 removidas, 1 nova, 2 ajustadas
Pedido explícito do usuário, revisitando as 10 regras da rodada 92: removeu tolerância de centavos de `balanceamento_ativo_passivo`/`debito_credito_divergente` (agora exigem diferença exatamente zero); adicionou `lucro_balancete_diverge_dre` (alta) — resultado do exercício precisa ser o mesmo valor no Balancete (conta "2.04.13.002", código fixo calibrado contra os 2 balancetes reais já em produção) e na DRE; ajustou `conta_transitoria_com_saldo` pra não confundir "dinheiro em trânsito" (`NUMERÁRIOS EM TRANSITO`) com conta transitória de verdade; e reescreveu `variacao_atipica_dre` pra usar a seção "Demonstração Mensal (Análise Vertical)" do próprio PDF em vez do histórico de apurações anteriores do Portal — passou a rodar mesmo na 1ª apuração de uma empresa, desde que o PDF traga essa seção. `variacao_atipica_saldo` (Balancete) e `percentual_custo_receita_atipico` foram removidas: a Análise Vertical não cobre contas do Balancete, e a granularidade maior da nova `variacao_atipica_dre` cobriu em espírito a segunda. Motor passou de 10 pra 9 regras.
Duas descobertas testando contra os 2 balancetes reais já em produção antes de decidir a implementação (`AskUserQuestion` em ambos os casos, não implementado de cabeça): (1) o código de classificação certo pro "lucro do balancete" é o da linha **sintética** "LUCROS/PREJUÍZOS DO EXERCÍCIO" (`2.04.13.002`, que agrega "LUCROS DO EXERCÍCIO" ou "(-) PREJUÍZOS DO EXERCÍCIO" conforme o resultado do mês), negada de sinal (convenção Passivo/PL) — bateu exato nos 2 arquivos antes de virar código; (2) a ideia original de trocar o trecho buscado de "TRANSIT" pra "TRANSITOR" (pra excluir "trânsito") quebraria a regra de verdade num PDF real (`1751`): a fonte desse cliente corrompe o acento de "TRANSITÓRIA" num caractere ilegível na extração, então "TRANSITOR" nunca bateria com uma conta transitória real que tem saldo (R$ 4.141,25) — corrigido excluindo só a palavra isolada "TRANSITO" (`\bTRANSITO\b`) do match de "TRANSIT", em vez de trocar o critério positivo inteiro.
Também confirmado testando os 2 arquivos reais: a Análise Vertical do PDF já vem com o valor **isolado por mês** (não acumulado desde janeiro como a DRE principal) e o `percentual` é a análise vertical de verdade (% da linha sobre a Receita Bruta daquele mês, não uma variação mês a mês) — a nova regra compara esse percentual entre os 2 meses mais recentes da própria tabela. Frontend (`dashboard-contabil.js`): `PID_DC_REGRAS`/`PID_DC_GRUPOS` atualizados pras 9 chaves atuais, "Lucro Balancete x DRE" entrou no grupo "Divergências de Saldo", "Variações e Indicadores" ficou só com "Variação Atípica na DRE". Detalhe completo (incluindo por que `historico`/`_contabil_monta_historico` continuam existindo mesmo sem regra nenhuma os usando hoje) no `CLAUDE.md` desta pasta.
### 130. Relatório do cliente: observações acima da tabela + clique leva até a conta
Pedido explícito do usuário: no relatório "Gerar Dashboard" (o HTML enviado ao cliente), a seção "Observações do Balancete/D.R.E./Análise Vertical" vinha depois da tabela em cada aba — passou pra **antes**, nas três abas. Além disso, clicar numa observação agora rola até a conta/linha que ela referencia, "se houver" — uma observação histórica cuja conta saiu do plano de contas desta apuração continua aparecendo normalmente, só sem virar link.
`ContabilApuracaoViewSet.dashboard()` calcula, pra cada observação visível, uma `ancora` (atributo Python setado direto no objeto, não um campo do model) igual ao `data-dcr-id` já usado pela linha correspondente na árvore (`conta-{id}`/`linha-{id}`/`av-{id}`), casando pela mesma chave natural que já indexa as observações por conta/linha — `None` quando a conta/linha não existe mais nesta apuração. No template, `pidDcrArvore()` passou a devolver `{ expandeAte }` (reabre só a cadeia de ancestrais colapsados até uma linha, sem mexer no resto da árvore) e uma função nova, `pidDcrObsResumo()`, liga o clique/teclado num item de observação a isso + `scrollIntoView` + um flash de destaque de 1,2s na linha. Validado ponta a ponta (`Client.force_login()`, rollback forçado): observação com conta real ganhou a âncora certa, observação órfã não ganhou âncora nem virou clicável, e as duas seções aparecem antes da tabela no HTML gerado. Detalhe completo no `CLAUDE.md` desta pasta.
**Bug reportado pelo usuário testando em produção, mesma rodada**: "clicar nas observações ainda não funciona". Validei de novo com um Chromium headless (Playwright) contra o relatório real gerado da apuração da própria empresa do print (dados reais, sem alterar nada) e o clique funcionou perfeitamente — o que apontou pra fora do código. Causa real: a mudança que faltava (cálculo da `ancora`) mora em `views.py`, e o `runserver` do usuário ainda não tinha reiniciado pra carregar essa versão — a reordenação de HTML (só template, recarregado a cada request) já aparecia, mascarando que só a parte dependente do `.py` estava desatualizada. Reiniciar o `runserver` resolveu. Lição registrada na memória do projeto pra rodadas futuras.
### 131. Relatório do cliente: "Limpar formatação" por tabela + "Voltar ao topo"
Pedido explícito do usuário, complementando a rodada 130: um botão "Limpar formatação" em cada tabela (Balancete/D.R.E./Análise Vertical, no cabeçalho da seção) desfaz qualquer expandir/recolher/destaque que o clique numa observação (ou o próprio usuário) tenha deixado, voltando pro estado inicial de quando a página carregou — sem precisar recarregar o relatório inteiro. E um botão flutuante "Voltar ao topo" (canto inferior direito, aparece só depois de rolar ~320px) rola a página de volta ao início.
`pidDcrArvore()` ganhou `resetar()` (devolve `colapsadas`/`destaque`/`historico` pro estado de `data-dcr-colapsado-padrao` e limpa qualquer `.dcr-row-flash` restante) e `pidDcrObs()` ganhou `fecharTudo()` (fecha todo painel de observação inline aberto) — os dois retornados junto de `expandeAte`/nada, respectivamente. O botão de cada tabela (`data-dcr-reset="dcr-{balancete,dre,av}-body"`) chama os dois de uma vez, só na própria tabela (não afeta as outras abas). "Voltar ao topo" é puramente `window.scrollY`/`scrollTo`, sem nenhuma dependência de dado. Os dois botões somem em `@media print`, mesmo padrão de "Imprimir"/"Exportar XLSX". Detalhe completo no `CLAUDE.md` desta pasta.
### 132. Relatório do cliente: exportar Resumo em PDF avulso + gráfico de evolução removido
Dois pedidos explícitos do usuário na mesma rodada. (1) Removido o gráfico "Evolução do Resultado Líquido" da aba Resumo — "temos a análise vertical para esta visualização", a aba Análise Vertical já cobre esse tipo de comparação mês a mês. Saiu o `<script>` do Chart.js (CDN), o `<canvas>`, `inicializaGrafico()`, o `{{ evolucao|json_script }}` e o loop que montava essa lista em `dashboard()` (`views.py`) — `_contabil_monta_historico_completo()` continua existindo, só perdeu esse consumidor (o cálculo de Depreciação/Amortização do EBITDA continua usando).
(2) Novo botão "Exportar PDF" na aba Resumo (`ContabilApuracaoViewSet.resumo_pdf()`, `GET /api/contabil-apuracoes/{id}/resumo-pdf/`) gera um PDF avulso com Resumo do Fechamento (texto rico convertido em flowables do reportlab, incluindo listas e imagens embutidas) + Indicadores (uma tabela por grupo) + Observações da Análise (mesmo conteúdo/agrupamento de "Todas as Observações da Análise") — sem o resto do relatório. Identidade visual do escritório (banner roxo/dourado com `logo-branco.png`), mesmo padrão de `custo_contratacao/pdf.py`/`indicadores/recibo.py`. `_contabil_dados_resumo()` (nova função em `views.py`) foi extraída de dentro de `dashboard()` pra ser reaproveitada pelos dois caminhos (indicadores + observações visíveis). Novo módulo `portal_api/dashboard_contabil/resumo_pdf.py` — HTML→reportlab via BeautifulSoup (já era dependência transitiva do projeto, nenhuma adicionada), cobrindo só o allowlist fechado de tags que o editor do Resumo já permite (`RICHTEXT_ALLOWED_TAGS`). Validado com apuração real via `Client.force_login()` e testes unitários isolados (negrito/itálico/lista/imagem, resumo vazio, zero observações) — todos os PDFs conferidos com `pdfplumber`. Detalhe completo no `CLAUDE.md` desta pasta.

View File

@ -22,26 +22,24 @@ 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`.
- **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.
- **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 — decisão revisitada numa rodada seguinte, ver `regra_variacao_atipica_dre` abaixo). É 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; **cada valor já vem isolado por mês** (não acumulado desde janeiro como a DRE principal — confirmado comparando os dois: `linhas_dre` de julho é o YTD jan-jul, `linhas_analise_vertical` de julho é só o mês de julho). `percentual` é a análise vertical de verdade (percentual da linha sobre a Receita Operacional Bruta *daquele mês*, não uma variação percentual mês a mês). `_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.
- `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).
## Regras de auditoria v1 (`regras.py`)
Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]`. `historico` vem ordenado da apuração mais recente pra mais antiga (só as últimas 2 já persistidas da mesma empresa, resolvidas por `_contabil_monta_historico()` em `views.py`).
Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]`. `historico` (só as últimas 2 apurações já persistidas da mesma empresa, resolvidas por `_contabil_monta_historico()` em `views.py`) continua na assinatura de toda regra por uniformidade (`gera_achados()` chama todas do mesmo jeito) — mas **nenhuma das 9 regras atuais usa esse parâmetro** desde que `variacao_atipica_dre` migrou pra Análise Vertical (rodada seguinte, ver abaixo); as duas regras que dependiam de `historico` (`variacao_atipica_saldo`/`percentual_custo_receita_atipico`) foram removidas na mesma rodada. `_contabil_monta_historico`/o parâmetro `busca_historico` de `pipeline.processa_apuracao()` continuam existindo de propósito (ponto de extensão já desenhado — ver "`ContabilApuracaoViewSet.create()`" abaixo), não foram arrancados só porque nada os usa hoje.
1. **`balanceamento_ativo_passivo`** (alta) — soma do grupo Ativo (`codigo="1"`) deve fechar com a do Passivo (`codigo="2"`, já vem negativo no relatório).
2. **`debito_credito_divergente`** (alta) — soma de Débito das contas-raiz (`codigo` sem ponto, ou seja só "1" e "2") deve bater com a soma de Crédito. **Não é uma checagem trivial de "todo balancete sempre bate"**: como a DRE (Resultado) não tem colunas de débito/crédito próprias neste relatório (só um valor líquido por linha), a identidade só fecha porque a movimentação de Resultado também transita pelas contas de Patrimônio Líquido do Passivo (ex.: "LUCROS/PREJUÍZOS DO EXERCÍCIO") — confirmado empiricamente contra `792 - balancete 072026.pdf` (débito total = crédito total = R$ 416.271.243,32 nas contas-raiz).
1. **`balanceamento_ativo_passivo`** (alta) — soma do grupo Ativo (`codigo="1"`) deve fechar **exatamente** com a do Passivo (`codigo="2"`, já vem negativo no relatório) — diferença precisa ser zero, sem tolerância de centavos (removida numa rodada seguinte, pedido explícito do usuário).
2. **`debito_credito_divergente`** (alta) — soma de Débito das contas-raiz (`codigo` sem ponto, ou seja só "1" e "2") deve bater **exatamente** com a soma de Crédito (mesma remoção de tolerância). **Não é uma checagem trivial de "todo balancete sempre bate"**: como a DRE (Resultado) não tem colunas de débito/crédito próprias neste relatório (só um valor líquido por linha), a identidade só fecha porque a movimentação de Resultado também transita pelas contas de Patrimônio Líquido do Passivo (ex.: "LUCROS/PREJUÍZOS DO EXERCÍCIO") — confirmado empiricamente contra `792 - balancete 072026.pdf` (débito total = crédito total = R$ 416.271.243,32 nas contas-raiz).
3. **`saldo_negativo_caixa`** (alta) — conta com `codigo` começando em `1.01.01.001` (grupo Caixa) e `saldo_atual < 0`.
4. **`conta_transitoria_com_saldo`** (média) — descrição contém "TRANSIT" (cobre "TRANSITÓRIA"/"TRANSITORIA") com `saldo_atual != 0`.
5. **`conta_deveria_zerar`** (média) — `TRECHOS_CONTA_DEVERIA_ZERAR` (lista curta e deliberadamente restrita — só "ADIANTAMENTOS DE SALÁRIOS", que o ITD confirma dever ficar zerada todo mês; **não** inclui "Adiantamento de Férias"/"13º Salário", que legitimamente carregam saldo entre meses).
6. **`saldo_sinal_invertido`** (média) — conta analítica (`tipo="A"`) do Ativo (`1.`) com saldo credor, ou do Passivo (`2.`) com saldo devedor, exceto contas redutoras (descrição começando com `"(-)"`, que são esperadas ter o sinal oposto ao grupo).
7. **`descricao_generica`** (baixa) — descrição exatamente `"DIVERSOS"` com saldo relevante (o ITD cita esse caso especificamente: "o contador deverá realocar estes lançamentos a conta pertinente").
8. **`variacao_atipica_saldo`** (média) — compara `saldo_atual` de cada conta analítica contra a apuração anterior da mesma empresa (por `codigo`), sinaliza quando a variação passa de `VARIACAO_LIMIAR_PERCENTUAL` (50%) **e** `VARIACAO_VALOR_MINIMO` (R$1.000, evita ruído em contas de valor irrisório). Só roda a partir da 2ª apuração de uma empresa.
9. **`variacao_atipica_dre`** (média) — mesma ideia, mas isolando o mês: como a DRE do relatório é **acumulada desde janeiro**, `_isola_mes_dre()` subtrai o acumulado do mês anterior (`valor_ytd_atual - valor_ytd_anterior`), exceto em janeiro (onde o acumulado já é o próprio mês). Só roda quando há **2** apurações anteriores consecutivas (`_mes_consecutivo()`) — precisa isolar tanto o mês atual quanto o anterior pra comparar mês-contra-mês de verdade, não acumulado-contra-acumulado.
10. **`percentual_custo_receita_atipico`** (média) — compara o percentual Custos/Receita Líquida do mês isolado atual contra o do mês isolado anterior (mesma técnica da regra 9) — o ITD é explícito que não existe parâmetro geral entre empresas pra essa relação, só comparação histórica da própria empresa.
4. **`saldo_sinal_invertido`** (média) — conta analítica (`tipo="A"`) do Ativo (`1.`) com saldo credor, ou do Passivo (`2.`) com saldo devedor, exceto contas redutoras (descrição começando com `"(-)"`, que são esperadas ter o sinal oposto ao grupo).
5. **`lucro_balancete_diverge_dre`** (alta, rodada seguinte) — o resultado do exercício (lucro **ou** prejuízo) precisa ser o mesmo valor no Balancete e na DRE, sem tolerância. Lê `CODIGO_LUCRO_PREJUIZO_EXERCICIO` (`"2.04.13.002"`, código de classificação fixo pra linha sintética "LUCROS/PREJUÍZOS DO EXERCÍCIO" dentro do Patrimônio Líquido — calibrado contra os 2 balancetes reais já em produção, mesmo padrão de risco de `CODIGO_CAIXA`/`indicadores.CODIGO_*`; agrega "LUCROS DO EXERCÍCIO" ou "(-) PREJUÍZOS DO EXERCÍCIO" conforme o resultado do mês), negado (mesma convenção Passivo/PL com sinal invertido de `balanceamento_ativo_passivo`) contra `linhas_dre[-1].valor` (última linha da DRE — mesma fonte que `_ContabilDadosIndicadores.resultado_liquido` em `views.py` já usa pros indicadores). Validado batendo exato contra os 2 balancetes reais antes de entrar em produção.
6. **`conta_transitoria_com_saldo`** (média) — descrição contém "TRANSIT" com `saldo_atual != 0`, **exceto** a palavra isolada "TRANSITO" (`\bTRANSITO\b`, "dinheiro em trânsito" — conceito diferente de conta transitória/de compensação, excluído numa rodada seguinte). A exclusão é por palavra isolada, não um trecho maior como "TRANSITOR": testando contra `1751 - Balancete 07.2026.pdf` antes de decidir, a fonte embutida corrompe o acento de "TRANSITÓRIA" num caractere ilegível (não recuperável) na extração — "TRANSITOR" nunca bateria com essa conta (que tem saldo real), então a mudança pra um trecho positivo mais longo foi descartada a favor de manter "TRANSIT" e só excluir o falso positivo conhecido.
7. **`conta_deveria_zerar`** (média) — `TRECHOS_CONTA_DEVERIA_ZERAR` (lista curta e deliberadamente restrita — só "ADIANTAMENTOS DE SALÁRIOS", que o ITD confirma dever ficar zerada todo mês; **não** inclui "Adiantamento de Férias"/"13º Salário", que legitimamente carregam saldo entre meses).
8. **`descricao_generica`** (baixa) — descrição exatamente `"DIVERSOS"` com saldo relevante (o ITD cita esse caso especificamente: "o contador deverá realocar estes lançamentos a conta pertinente").
9. **`variacao_atipica_dre`** (média, **reescrita numa rodada seguinte** — pedido explícito do usuário, "utilizar a análise vertical") — não depende mais de `historico`/apurações anteriores do Portal. Usa a própria seção "Demonstração Mensal (Análise Vertical)" do PDF (`atual.linhas_analise_vertical`, quando presente): compara só os **2 meses mais recentes** dessa tabela (ex. jun → jul), pelo `percentual` (já isolado por mês, não acumulado) de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos `VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO` (1 ponto percentual — piso pra não disparar em saltos %-mente grandes só porque a base de comparação já era perto de zero) **e**, quando o percentual anterior não é zero, `VARIACAO_LIMIAR_PERCENTUAL` (50% de variação relativa sobre ele). Roda mesmo na 1ª apuração de uma empresa nova, desde que o PDF traga essa seção — validado contra os 2 balancetes reais (16 e 15 achados respectivamente; ambos com bastante movimento real entre os 2 meses do próprio PDF).
Regras 8-10 não geram achado nenhum na primeira apuração de uma empresa (sem histórico ainda) — comportamento esperado, não bug.
**Duas regras removidas na mesma rodada** (pedido explícito do usuário, "utilizar a análise vertical" no lugar delas): `variacao_atipica_saldo` (variação de saldo de conta do Balancete contra a apuração anterior) — a Análise Vertical do PDF só cobre linhas da DRE, não contas do Balancete, então não tinha como reaproveitar a mesma fonte pra ela, e a alternativa de mantê-la como estava (usando `historico`) foi descartada a favor de simplificar o motor; `percentual_custo_receita_atipico` (razão Custos/Receita Líquida do mês contra o mês anterior, via `historico`) — a granularidade maior de `variacao_atipica_dre` sobre a Análise Vertical já cobre esse caso (e qualquer outra linha da DRE) sem precisar de uma regra dedicada.
## Models (`portal_api/models.py`)
@ -78,9 +76,9 @@ Diferente de `IndicadorApuracaoViewSet`/`ImportacaoPlanoSaudeViewSet` (onde a ch
**Lista de Observações ordenada por severidade** (pedido explícito do usuário): `achadosFiltrados()` filtra e depois ordena (`PID_DC_SEVERIDADE_ORDEM = {alta: 0, media: 1, baixa: 2}`) — Alta sempre primeiro, Baixa por último, preservando a ordem original (ordem em que as regras rodaram) dentro de uma mesma severidade, já que `Array.prototype.sort` é estável. Puramente ordenação de exibição no frontend, nada mudou no backend/model.
**Resumo clicável (donut por severidade + cards por grupo temático) no topo da aba Observações** (`.dc-achados-resumo`, `dashboard-contabil.html`/`.css`/`.js`, pedido explícito do usuário, inspirado numa tela de auditoria de outro sistema — ver rodadas 97/98 do `CHANGELOG.md`): acima dos chips de filtro, um donut em SVG puro (sem Chart.js — essa dependência só existe no relatório estático "Gerar Dashboard", não faz sentido carregar aqui numa tela interativa pequena) com a quantidade de observações por **severidade** (Alta/Média/Baixa, mesmas cores dos badges — `--danger`/`--gold`/`rgb(var(--slate-rgb))`) e o total no centro, mais uma grade de cards. `PID_DC_REGRAS` (constante no topo do arquivo) enumera as **10 regras de `regras.py`** por chave (`achado.regra`, campo que já existia no model `ContabilAchado`).
**Resumo clicável (donut por severidade + cards por grupo temático) no topo da aba Observações** (`.dc-achados-resumo`, `dashboard-contabil.html`/`.css`/`.js`, pedido explícito do usuário, inspirado numa tela de auditoria de outro sistema — ver rodadas 97/98 do `CHANGELOG.md`): acima dos chips de filtro, um donut em SVG puro (sem Chart.js — essa dependência só existe no relatório estático "Gerar Dashboard", não faz sentido carregar aqui numa tela interativa pequena) com a quantidade de observações por **severidade** (Alta/Média/Baixa, mesmas cores dos badges — `--danger`/`--gold`/`rgb(var(--slate-rgb))`) e o total no centro, mais uma grade de cards. `PID_DC_REGRAS` (constante no topo do arquivo) enumera as **9 regras de `regras.py`** por chave (`achado.regra`, campo que já existia no model `ContabilAchado`) — eram 10 até uma rodada seguinte, quando `lucro_balancete_diverge_dre` entrou e `variacao_atipica_saldo`/`percentual_custo_receita_atipico` saíram (ver "Regras de auditoria v1" acima).
**Cards agrupados por tema, não um card por regra** (rodada seguinte, pedido explícito do usuário: a versão original — um card por regra, 10 ao todo, cada um com uma mini-tabela de conta+status por achado — ficou "muito poluída visualmente"). `PID_DC_GRUPOS` (constante logo abaixo de `PID_DC_REGRAS`) agrupa as 10 regras em 3 temas fixos: "Divergências de Saldo" (balanceamento Ativo x Passivo, débito x crédito, caixa negativo, sinal de saldo invertido), "Contas Atípicas" (contas transitórias, contas que deveriam zerar, descrição genérica) e "Variações e Indicadores" (variação atípica de saldo, variação atípica na DRE, percentual custo/receita) — agrupamento alinhado com o usuário antes de implementar (via pergunta com preview), não inferido sozinho. Cada card mostra só o total do grupo no cabeçalho e, por baixo, uma linha por regra (label + contagem, sem mini-tabela de conta/status) — regra sem nenhum achado nesta apuração continua listada com contagem `0`, só sem estar clicável (mesmo espírito de sempre mostrar as 10 regras, mesmo as que "passaram"). O detalhe por conta/status de cada achado (antes replicado dentro de cada card) não foi removido, só saiu do resumo — continua disponível na lista completa logo abaixo, ao clicar numa linha de regra pra filtrar. `renderAchadosResumo()` (chamada no início de `renderAchados()`, então atualiza sozinha a cada mudança de status/filtro) conta sempre sobre `apuracaoAtual.achados` **completo**, nunca sobre `achadosFiltrados()` — é uma visão geral estável, não deve mudar quando o usuário filtra a lista detalhada logo abaixo. As cores dos 3 grupos (`PID_DC_CATEGORIA_CORES`, mesma constante de antes, agora indexada por grupo em vez de por regra) reaproveitam os tokens `--accent-rgb`/`--danger-rgb`/`--gold-rgb` de `tokens.css` (já theme-aware, acompanham tema claro/escuro e a cor de tema escolhida pelo usuário), sem nenhum hex novo hardcoded. Decisão explícita de escopo (alinhada por pergunta ao usuário antes de implementar): **não** foi replicado o checklist pass/fail de checagens do sistema de referência (várias delas — folha, vencimento de fornecedor/cliente/imposto, saldo bancário — dependem de dado fora do Balancete/DRE anexado, fora do escopo já documentado desta ferramenta, ver "Decisões de escopo" acima) nem essa visualização foi levada pro relatório "Gerar Dashboard" (só a tela de revisão do Portal).
**Cards agrupados por tema, não um card por regra** (rodada seguinte à criação do resumo, pedido explícito do usuário: a versão original — um card por regra, 10 ao todo na época, cada um com uma mini-tabela de conta+status por achado — ficou "muito poluída visualmente"). `PID_DC_GRUPOS` (constante logo abaixo de `PID_DC_REGRAS`) agrupa as 9 regras atuais em 3 temas fixos: "Divergências de Saldo" (balanceamento Ativo x Passivo, débito x crédito, caixa negativo, sinal de saldo invertido, lucro do balancete x DRE), "Contas Atípicas" (contas transitórias, contas que deveriam zerar, descrição genérica) e "Variações e Indicadores" (variação atípica na DRE, única regra do grupo desde que as outras duas saíram) — agrupamento original alinhado com o usuário antes de implementar (via pergunta com preview), mantido ao remapear as regras novas/removidas pros mesmos 3 temas. Cada card mostra só o total do grupo no cabeçalho e, por baixo, uma linha por regra (label + contagem, sem mini-tabela de conta/status) — regra sem nenhum achado nesta apuração continua listada com contagem `0`, só sem estar clicável (mesmo espírito de sempre mostrar todas as regras, mesmo as que "passaram"). O detalhe por conta/status de cada achado (antes replicado dentro de cada card) não foi removido, só saiu do resumo — continua disponível na lista completa logo abaixo, ao clicar numa linha de regra pra filtrar. `renderAchadosResumo()` (chamada no início de `renderAchados()`, então atualiza sozinha a cada mudança de status/filtro) conta sempre sobre `apuracaoAtual.achados` **completo**, nunca sobre `achadosFiltrados()` — é uma visão geral estável, não deve mudar quando o usuário filtra a lista detalhada logo abaixo. As cores dos 3 grupos (`PID_DC_CATEGORIA_CORES`, mesma constante de antes, agora indexada por grupo em vez de por regra) reaproveitam os tokens `--accent-rgb`/`--danger-rgb`/`--gold-rgb` de `tokens.css` (já theme-aware, acompanham tema claro/escuro e a cor de tema escolhida pelo usuário), sem nenhum hex novo hardcoded. Decisão explícita de escopo (alinhada por pergunta ao usuário antes de implementar): **não** foi replicado o checklist pass/fail de checagens do sistema de referência (várias delas — folha, vencimento de fornecedor/cliente/imposto, saldo bancário — dependem de dado fora do Balancete/DRE anexado, fora do escopo já documentado desta ferramenta, ver "Decisões de escopo" acima) nem essa visualização foi levada pro relatório "Gerar Dashboard" (só a tela de revisão do Portal).
**Donut e linhas de regra são clicáveis, filtram a lista detalhada abaixo** (pedido explícito do usuário, rodada 98; a granularidade do clique por regra individual foi preservada na reorganização em grupos da rodada seguinte): cada fatia do donut (ou item da legenda) chama `pidDcSelecionarSeveridade(severidade)` — a mesma função que os chips "Alta"/"Média"/"Baixa" já usavam (extraída pra função reaproveitável, sem duplicar a lógica de atualizar `filtroSeveridade`+classe `.is-active`+`renderAchados()`); clicar numa linha de regra com contagem > 0 alterna `filtroRegra` (`achado.regra` exata ou `null`) — clicar de novo na mesma linha limpa o filtro; o card do grupo em si (cabeçalho) não é clicável, só as linhas de regra dentro dele. `achadosFiltrados()` ganhou uma terceira condição (`filtroRegra`) que se combina por E lógico com severidade/status já existentes — os três filtros funcionam juntos, não um substitui o outro. Como não existe um chip próprio pro filtro por categoria, uma faixa nova (`#dc-regra-filtro-ativo`, escondida quando `filtroRegra` é `null`) aparece entre os chips e a lista mostrando o nome da regra ativa + um botão "Limpar" — sem essa faixa não haveria como o usuário perceber por que a lista ficou filtrada nem como sair do filtro sem adivinhar que precisa clicar de novo na linha. Clicar em qualquer um dos dois (donut/linha de regra) também dá um `scrollIntoView` suave até `#dc-achados-list` (`pidDcScrollParaLista()`), já que o resumo pode empurrar a lista pra fora da tela em telas menores. O donut usa a técnica clássica de pizza/donut em SVG com `<circle r="15.9155">` (circunferência ≈ 100, então `stroke-dasharray`/`stroke-dashoffset` já funcionam direto em unidades de percentual, sem precisar de `pathLength`) — cada segmento é um `<circle>` próprio com seu `stroke-dashoffset` acumulado (offset inicial `25` desloca o início de "3 horas" pra "12 horas"), clicável individualmente porque `pointer-events` de SVG só considera pixel realmente pintado pelo `stroke`, sem precisar de hit-test manual por ângulo.
@ -106,7 +104,7 @@ Cada PATCH é individual (`ContabilContaViewSet`/`ContabilLinhaDreViewSet` conti
## Relatório "Gerar Dashboard" (`indicadores.py` + `dashboard-contabil-relatorio.html`)
`ContabilApuracaoViewSet.dashboard()` (`GET /api/contabil-apuracoes/{id}/dashboard/`, mesma permissão de toggle único das outras actions) gera um documento HTML autocontido (não estende o shell do Portal — nunca a marca "P.I.D.", ver "Logos" no CLAUDE.md raiz) com os indicadores financeiros, a DRE/Balancete agrupados por nível (recolhível, ver "Árvore recolhível" abaixo), um gráfico de evolução do Resultado Líquido e as observações que o contador já registrou (contas, linhas de DRE, achados tratados/ignorados com `observacao_contador`). Escopo confirmado com o usuário: **sempre uma apuração por vez** (a que está sendo revisada), sem "Filial" (não existe no modelo) nem consolidação entre empresas — isso ficaria pra um BI à parte, fora de escopo aqui.
`ContabilApuracaoViewSet.dashboard()` (`GET /api/contabil-apuracoes/{id}/dashboard/`, mesma permissão de toggle único das outras actions) gera um documento HTML autocontido (não estende o shell do Portal — nunca a marca "P.I.D.", ver "Logos" no CLAUDE.md raiz) com os indicadores financeiros, a DRE/Balancete agrupados por nível (recolhível, ver "Árvore recolhível" abaixo) e as observações que o contador já registrou (contas, linhas de DRE, achados tratados/ignorados com `observacao_contador`). Escopo confirmado com o usuário: **sempre uma apuração por vez** (a que está sendo revisada), sem "Filial" (não existe no modelo) nem consolidação entre empresas — isso ficaria pra um BI à parte, fora de escopo aqui.
**É GET, não POST** (diferente do padrão `/gerar/` de outras ferramentas, que mutam estado ou recebem multipart) — decisão de uma rodada seguinte, corrigindo um bug real: a primeira versão era POST, e o frontend chamava via `fetch` + `URL.createObjectURL(blob)` + `window.open(url)` pra abrir numa nova aba (mesmo padrão de `pidGerarArquivoPlanoSaude`). Só que um documento carregado de uma URL `blob:` tem uma origem sintética — URLs relativas dentro do HTML (como as que `{% static %}` gera, ex. `/static/img/logo-branco.png`) não resolvem de forma confiável contra a origem real do Portal nesse contexto, e a logo do escritório no cabeçalho simplesmente não carregava. Com GET, o frontend abre a URL da API direto (`window.open(`/api/contabil-apuracoes/${id}/dashboard/`, "_blank")`, `pidGerarDashboardContabil()` em `dashboard-contabil.js`) — navegação de verdade, mesma origem, sem blob nenhum de permeio; `{% static %}` funciona igual a qualquer outra página do Portal. Também simplificou o JS (sem `pidEnsureCsrfCookie`/CSRF manual — GET não precisa).
@ -125,7 +123,7 @@ Códigos calibrados: Ativo Total `"1"`, Ativo Circulante `"1.01"`, Estoques `"1.
**Aproximado, sem exemplo real pra validar**: Liquidez Geral = Ativo Circulante / Exigível Total — trata o Realizável a Longo Prazo como indisponível/0, porque o Ativo Não Circulante (`"1.02"`) hoje mistura Investimentos/Imobilizado com um eventual Realizável a Longo Prazo, sem separar (o parser não distingue isso). Coincide com a Liquidez Corrente quando a empresa não tem Passivo Não Circulante (era o caso do balancete de referência).
**DRE**: `resultado_liquido` é passado pra `calcula_indicadores()` já resolvido pela view (`linhas_dre[-1].valor`, a última linha na ordem do relatório) — não por texto, mais seguro. EBIT = Resultado Líquido − linha "(+/-) Despesas/Receitas Financeiras" (casada por substring via `LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS` em `parser.py`, **não validada** contra um PDF real como `LINHA_DRE_RECEITA_LIQUIDA`/`LINHA_DRE_CUSTOS_TOTAIS` já são). Depreciação/Amortização do mês = variação do saldo da conta de depreciação acumulada entre a apuração atual e a anterior da mesma empresa — **indisponível na primeira apuração de uma empresa** (mesmo espírito das regras 8-10 de auditoria, que também dependem de histórico); EBITDA = EBIT + essa variação, também indisponível quando ela for. ROA = Resultado Líquido / Ativo Total; ROE = Resultado Líquido / Patrimônio Líquido.
**DRE**: `resultado_liquido` é passado pra `calcula_indicadores()` já resolvido pela view (`linhas_dre[-1].valor`, a última linha na ordem do relatório) — não por texto, mais seguro (mesma fonte que `regra_lucro_balancete_diverge_dre` usa em `regras.py`, ver "Regras de auditoria v1" acima). EBIT = Resultado Líquido − linha "(+/-) Despesas/Receitas Financeiras" (casada por substring via `LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS` em `parser.py`, **não validada** contra um PDF real). Depreciação/Amortização do mês = variação do saldo da conta de depreciação acumulada entre a apuração atual e a anterior da mesma empresa — **indisponível na primeira apuração de uma empresa**; EBITDA = EBIT + essa variação, também indisponível quando ela for. ROA = Resultado Líquido / Ativo Total; ROE = Resultado Líquido / Patrimônio Líquido.
**Kanitz (Termômetro de Insolvência)** usa a fórmula-livro-texto padrão (`FI = 0,05×ROE + 1,65×LiquidezGeral + 3,55×LiquidezSeca − 1,06×LiquidezCorrente − 0,33×GrauEndividamento`), **não validada** contra o BI antigo que este Dashboard substitui — o valor mostrado numa captura do usuário ("11,31") está fora da faixa clássica do índice (−7 a +7), sugerindo que o BI antigo usa uma variação/escala diferente. O relatório marca esse card como estimativa; ajustar se o usuário trouxer a fórmula exata usada pelo BI antigo.
@ -135,7 +133,7 @@ Todo indicador que pode ficar indisponível é `Decimal | None` no dataclass `In
Documento HTML autocontido — `<style>` inline com a mesma paleta já usada nos documentos gerados (`indicadores/recibo.py`: roxo `#3d2178`, dourado `#b4872a`), cabeçalho com `static/img/logo-branco.png` (texto branco — não `logo.png`, que ficaria ilegível sobre o banner roxo escuro, ver "Logos em `static/img/`" no CLAUDE.md raiz). Botão "Imprimir" (`onclick="window.print()"`) escondido via `@media print`.
**Conteúdo dividido em 3 abas** (`.dcr-tabs`/`.dcr-tab`/`[data-dcr-panel]`, JS puro inline no próprio template — não reaproveita `.pa-tabs` de `perfis-acesso.css`, que não é carregado neste documento autocontido), nesta ordem pedida pelo usuário: **Balancete** (aba inicial) → **D.R.E.** → **Indicadores** (os dois grupos de cards + o gráfico de evolução, que fazem mais sentido juntos). Na impressão (`@media print`), a barra de abas some e as 3 ficam visíveis ao mesmo tempo, cada uma numa página própria (`page-break-after`) — documento impresso não deve esconder conteúdo atrás de uma aba não clicada.
**Conteúdo dividido em abas** (`.dcr-tabs`/`.dcr-tab`/`[data-dcr-panel]`, JS puro inline no próprio template — não reaproveita `.pa-tabs` de `perfis-acesso.css`, que não é carregado neste documento autocontido), nesta ordem: **Balancete** (aba inicial) → **D.R.E.** → **Análise Vertical** (só quando a apuração tem essa seção, ver rodada 123 abaixo) → **Resumo** (rótulo visível; `data-dcr-tab`/`data-dcr-panel` internos continuam `"indicadores"`, nome que nasceu antes de virar uma aba com mais coisa além dos cards — os dois grupos de indicador, o Resumo do Fechamento e as Observações consolidadas). Na impressão (`@media print`), a barra de abas some e todas ficam visíveis ao mesmo tempo, cada uma numa página própria (`page-break-after`) — documento impresso não deve esconder conteúdo atrás de uma aba não clicada.
**Observações espalhadas nas 3 abas, cada uma com o recorte certo** (pedido explícito do usuário — antes ficavam todas juntas numa única seção fora das abas): a aba **Balancete** termina com "Observações do Balancete" (só `observacoes_contas`); a aba **D.R.E.** termina com "Observações da D.R.E." (só `observacoes_dre`); a aba **Indicadores** termina com "Todas as Observações da Análise" (`observacoes_contas` + `observacoes_dre` + `achados_com_observacao` juntos, cada item com um prefixo indicando a origem — "Balancete — ...", "D.R.E. — ...", "Auditoria — ..." — já que aqui não há mais uma aba própria pra inferir o contexto). **Nunca "Achado"/"Achado de Auditoria" em texto visível** — pedido explícito do usuário, mesmo motivo pelo qual a aba de revisão já se chama "Observações" (`data-dc-tab="achados"` com o texto "Observações", `dashboard-contabil.html`) e não "Achados"; `achado`/`ContabilAchado`/`achados_com_observacao` continuam normais como nome de variável/model/classe, só não podem aparecer como palavra na tela. As três seções reaproveitam o mesmo markup (`.dcr-obs-lista`/`.dcr-obs-item`), só filtrando quais das três listas do contexto (`observacoes_contas`/`observacoes_dre`/`achados_com_observacao`, já vindas prontas de `views.py`) cada uma itera — nenhuma mudança no backend foi necessária, é só reorganização do template. Estado vazio próprio por seção ("Nenhuma observação registrada no Balancete."/"...na D.R.E."/"...nesta análise.").
@ -143,7 +141,7 @@ Documento HTML autocontido — `<style>` inline com a mesma paleta já usada nos
**Nasce recolhido a partir do "grupo 4", mesmo limiar da tela de revisão** (rodada seguinte, mesmo pedido — ver "Nasce recolhida a partir do 'grupo 4'" acima): `_contabil_arvore_contexto()` ganhou um terceiro campo por item, `colapsado_padrao` (`tem_filhos and nivel >= _CONTABIL_NIVEL_ABERTO_PADRAO`, constante módulo-level `= 3`), viram `data-dcr-colapsado-padrao="1"/"0"` em cada `<tr>`; o botão de toggle só ganha a classe `is-expanded` inicial quando `not item.colapsado_padrao`. `pidDcrArvore()` lê esse atributo **antes** do primeiro clique e semeia o objeto `colapsadas` (antes só populado por interação do usuário) com os ids marcados, chamando `atualiza()` uma vez na inicialização — o resto do algoritmo (pilha de níveis, alternar `hidden`) não mudou. Mesma constante conceitual dos dois lados (`_CONTABIL_NIVEL_ABERTO_PADRAO` em `views.py` / `PID_DC_NIVEL_ABERTO_PADRAO` em `dashboard-contabil.js`), duplicada porque um é Python renderizado uma vez e o outro é JS de uma SPA — se o limiar mudar, ajustar os dois.
Gráfico de evolução do Resultado Líquido via Chart.js (`<script src="https://cdn.jsdelivr.net/npm/chart.js@4">`, snippet oficial recomendado pela biblioteca) — série injetada com `{{ evolucao|json_script:"dcr-evolucao-data" }}` (usa `DjangoJSONEncoder`, que já serializa `Decimal`/`date` sem precisar de nenhum encoder customizado) e lida em JS puro. **Inicializado sob demanda**, não no carregamento da página: o `<canvas>` nasce dentro da aba "Indicadores", escondida por padrão (Balancete é a aba inicial), e Chart.js não desenha corretamente num canvas com largura/altura zero — `inicializaGrafico()` só roda no primeiro clique nessa aba (ou no evento `beforeprint`, pra garantir que o gráfico exista na versão impressa mesmo que a aba nunca tenha sido aberta na tela).
**Gráfico de evolução do Resultado Líquido removido numa rodada seguinte** (pedido explícito do usuário: "temos a análise vertical para esta visualização" — a aba Análise Vertical já cobre esse tipo de comparação mês a mês, o gráfico ficava redundante). Existia via Chart.js (`<script src="https://cdn.jsdelivr.net/npm/chart.js@4">`) com a série injetada por `{{ evolucao|json_script:"dcr-evolucao-data" }}`; os três (o `<script>` do CDN, o `json_script`, e a função `inicializaGrafico()`/`<canvas id="dcr-grafico-evolucao">`) foram removidos do template, e `evolucao`/o loop que a montava a partir de `dados.historico_completo` saiu de `dashboard()` em `views.py` — `_contabil_monta_historico_completo()` continua existindo, só não alimenta mais esse gráfico (ver "Depreciação/Amortização" acima, seu outro consumidor). A variável de controle que só existia pra adiar a inicialização do gráfico (`graficoInicializado`) virou `cardsInicializados`, já que sobrou só a contagem animada dos cards de indicador pra adiar.
`portal_api/templatetags/contabil_extras.py` — **primeiro uso de template tags customizadas no projeto** (precisou de `portal_api/templatetags/__init__.py`, auto-descoberto pelo Django por `portal_api` já estar em `INSTALLED_APPS`). Filtros `moeda`/`percentual`/`indice`/`competencia`, todos no padrão brasileiro (separador de milhar `.`, decimal `,`) — mesmo espírito do helper `_moeda()` que já existe, duplicado por arquivo, nos dois geradores de PDF (`indicadores/recibo.py`, `custo_contratacao/pdf.py`), mas como filtro reaproveitável, já que este template tem tabelas inteiras de valores monetários (Balancete/DRE), não um valor por vez. `None` sempre vira "—", nunca "R$ 0,00"/"0,00%" — ver acima por quê. Um quinto filtro, `numero_bruto`, existe só pra alimentar a animação de contagem dos cards (ver "Visual" abaixo) — devolve o valor cru (`str(float(valor))`, `""` se `None`) exclusivamente para um atributo `data-count`, nunca pro texto exibido.
@ -171,6 +169,21 @@ Cada planilha nasce com cabeçalho (título/empresa/CNPJ/competência, linhas 1-
Botão "Exportar XLSX" (`.dcr-export-btn`, `dashboard-contabil-relatorio.html`) é um `<a href="/api/contabil-apuracoes/{{ apuracao.id }}/exportar-xlsx/?parte=...">` puro — sem JS nenhum, mesmo espírito de link direto de download; o browser já lida com o `Content-Disposition: attachment` da resposta. Escondido em `@media print` junto do botão "Imprimir" (`.dcr-print-btn`), já que exportar não faz sentido numa versão impressa.
### Exportação em PDF do Resumo (`resumo_pdf.py`, rodada seguinte)
Pedido explícito do usuário: um "Exportar PDF" dentro da aba **Resumo** do relatório "Gerar Dashboard", pra baixar Resumo do Fechamento + Indicadores + Observações num documento avulso — sem o resto do relatório (Balancete/D.R.E./Análise Vertical, que já têm seu próprio caminho de exportação em XLSX). `ContabilApuracaoViewSet.resumo_pdf()` (`GET /api/contabil-apuracoes/{id}/resumo-pdf/`, mesma permissão de toggle único, mesmo `GET`-não-`POST` de `dashboard()` — abre via `window.open()`/link direto, não fetch+blob) chama `_contabil_dados_resumo(apuracao)` (a mesma função que `dashboard()` usa pra montar `indicadores_grupos`/observações da aba Resumo — extraída numa refatoração desta mesma rodada, ver função em `views.py`) e `dashboard_contabil.resumo_pdf.gera_pdf_resumo()`.
**`_contabil_dados_resumo(apuracao)`** (`views.py`) — extraído de dentro de `dashboard()` pra ser reaproveitado pelos dois: calcula `indicadores_grupos` (mesmo caminho de sempre — `_contabil_coleta_dados_indicadores`/`_contabil_calcula_indicadores_personalizados`/`_contabil_monta_cards_indicadores`/`_contabil_agrupa_indicadores_cards`) e devolve `observacoes_visiveis` **sem** separar por `alvo_tipo` nem calcular `ancora` (isso é específico do relatório HTML, que separa em 3 listas e pendura `ancora` pra permitir o clique-até-a-conta — ver rodada anterior) — o PDF só lista todas juntas, na mesma ordem/agrupamento de "Todas as Observações da Análise". `dashboard()` chama essa função e continua fazendo, por cima, a separação por tipo + `_com_ancora()` que só ele precisa.
**`resumo_pdf.py`** (pacote puro, sem ORM — recebe a `ContabilApuracao` já carregada e o dict de `_contabil_dados_resumo()`, mesmo espírito de `indicadores/recibo.py`/`custo_contratacao/pdf.py`): banner roxo/dourado com `logo-branco.png` (identidade do escritório, nunca "P.I.D." — ver "Logos" no CLAUDE.md raiz), replicando a paleta do próprio relatório HTML (`#3d2178`/`#281552`/`#b4872a`). Três seções, na mesma ordem da aba Resumo:
- **Resumo do Fechamento**: o texto rico do contador (`apuracao.resumo_fechamento`, já sanitizado por `nh3` — allowlist fechado `RICHTEXT_ALLOWED_TAGS` em `serializers.py`: `p`/`br`/`div`/`b`/`strong`/`i`/`em`/`u`/`ul`/`ol`/`li`/`img`) convertido em flowables do reportlab por `_resumo_fechamento_flowables()` (BeautifulSoup, já em `requirements.txt` como dependência transitiva de outra ferramenta — não precisou adicionar nada novo). `_inline_markup()` reconstrói `b`/`i`/`u`/`br` aninhados na marcação nativa que o `Paragraph` do reportlab já entende (`<b>`/`<i>`/`<u>`/`<br/>`), `strong`→`b`/`em`→`i`; `ul`/`ol` viram `ListFlowable`; `<img src="data:image/...">` (a única forma de imagem que o editor produz, colar/arrastar arquivo) vira um `Image` decodificado de base64 direto em memória, redimensionado pra caber na largura útil da página. Seção inteira pulada se `resumo_fechamento` estiver vazio.
- **Indicadores**: uma `Table` por grupo (`_tabela_indicadores()`, mesmo padrão de "seção = uma Table com barra de título" de `_tabela_secao()` em `custo_contratacao/pdf.py`) — nome/valor formatado de cada card, sem fórmula/descrição/ícone (esses só fazem sentido no verso do flip card da tela, não num PDF estático).
- **Observações da Análise**: todo `ContabilObservacao` visível + `achados_com_observacao`, cada um com o mesmo prefixo de origem do relatório HTML ("Balancete — ...", "D.R.E. — ...", "Análise Vertical — ...", "Auditoria — ..."), texto e assinatura. "Nenhuma observação registrada nesta análise." quando vazio (mesmo texto do relatório HTML).
Botão (`<a class="dcr-export-btn" href="/api/contabil-apuracoes/{{ apuracao.id }}/resumo-pdf/">Exportar PDF</a>`, `.dcr-resumo-toolbar` — uma barra dedicada no topo da aba Resumo, já que a aba tem várias seções sem um único `<h2>` fixo pra pendurar o botão, diferente de Balancete/D.R.E./Análise Vertical) — escondido em `@media print` (reaproveita a regra já existente de `.dcr-export-btn`, só precisou esconder o wrapper `.dcr-resumo-toolbar` também, pra não sobrar um espaço vazio).
Validado com `Client.force_login()` contra uma apuração real já em produção (leitura, nada escrito) e com testes unitários isolados de `_resumo_fechamento_flowables()`/`gera_pdf_resumo()` (sem tocar no banco) cobrindo negrito/itálico/lista/imagem embutida, resumo vazio e zero observações — os três casos renderizam um PDF válido (`pdfplumber` confirmou o texto esperado em cada página).
### Aba "Dashboard" na tela de revisão — ocultar cards/observações do relatório antes de gerar
Pedido explícito do usuário: uma 4ª aba na tela de revisão (`dashboard-contabil.html`, depois de Observações/Balancete/DRE, `data-dc-tab="dashboard"`) mostrando **antes de gerar o relatório** os mesmos 11 cards de indicador e a mesma lista de observações (contas + linhas de DRE + achados com `observacao_contador`) que vão pro relatório "Gerar Dashboard", com um botão de olho em cada item pra escondê-lo do relatório final — sem apagar o dado em si (a conta/linha/achado continua normal no Balancete/DRE/Observações da revisão, só não entra na versão que vai pro administrador da empresa).
@ -185,7 +198,7 @@ Pedido explícito do usuário: uma 4ª aba na tela de revisão (`dashboard-conta
- `POST /api/contabil-achados/{id}/alternar-oculto/` (`ContabilAchadoViewSet.alternar_oculto()`) — **não** reaproveita `update()`/`ContabilAchadoAjusteSerializer` (que exige `status` + `observacao_contador` não-vazia pra "tratar"/"ignorar"): ocultar do relatório é uma decisão independente de tratativa, um achado pode continuar "Pendente" e mesmo assim ter sua observação escondida caso um dia venha a ser preenchida sem mudar o status. Serializer próprio (`ContabilAchadoOcultoSerializer`, só `{oculto_no_relatorio: bool}`), gate igual. `http_method_names` do viewset ganhou `"post"` só por causa desta action (`GET`/`PATCH` continuam cobrindo o resto).
- `ContabilConta`/`ContabilLinhaDre` **não** precisaram de action nova — `oculta_no_relatorio` só entrou nos `fields` de `ContabilContaSerializer`/`ContabilLinhaDreSerializer` (ao lado de `observacao`, já gravável) e o `PATCH` genérico que essas duas telas já expõem (`ContabilContaViewSet`/`ContabilLinhaDreViewSet`, sem "Ajuste" nenhum no meio) aceita o campo isolado, sem exigir os demais.
**`dashboard()` filtra pelo que está oculto** (`views.py`): `observacoes_contas`/`observacoes_dre`/`achados_com_observacao` no contexto do relatório ganharam `and not X.oculta_no_relatorio`/`oculto_no_relatorio`; `indicadores_ocultos` (a lista crua) e dois booleanos por grupo (`indicadores_grupo_resultado_visivel`/`indicadores_grupo_liquidez_visivel`, `any(chave not in ocultos ...)`) entram no contexto pro template. `dashboard-contabil-relatorio.html` envolve cada um dos 11 `.dcr-card` num `{% if "chave" not in indicadores_ocultos %}` (o operador `in`/`not in` do Django Template Language já faz teste de pertencimento numa lista direto, sem precisar de filtro customizado novo) e cada um dos 2 `<h2>+.dcr-cards` de grupo num `{% if indicadores_grupo_*_visivel %}` — evita um cabeçalho de seção "Indicadores de Resultado" sobrando sozinho, sem nenhum card embaixo, se o contador ocultar os 5 de uma vez. O gráfico de evolução do Resultado Líquido e a exportação XLSX de Balancete/DRE **não** são afetados — não são "cards de indicador" nem "observações", ficaram fora do escopo desta rodada.
**`dashboard()` filtra pelo que está oculto** (`views.py`): `observacoes_contas`/`observacoes_dre`/`achados_com_observacao` no contexto do relatório ganharam `and not X.oculta_no_relatorio`/`oculto_no_relatorio`; `indicadores_ocultos` (a lista crua) e dois booleanos por grupo (`indicadores_grupo_resultado_visivel`/`indicadores_grupo_liquidez_visivel`, `any(chave not in ocultos ...)`) entram no contexto pro template. `dashboard-contabil-relatorio.html` envolve cada um dos 11 `.dcr-card` num `{% if "chave" not in indicadores_ocultos %}` (o operador `in`/`not in` do Django Template Language já faz teste de pertencimento numa lista direto, sem precisar de filtro customizado novo) e cada um dos 2 `<h2>+.dcr-cards` de grupo num `{% if indicadores_grupo_*_visivel %}` — evita um cabeçalho de seção "Indicadores de Resultado" sobrando sozinho, sem nenhum card embaixo, se o contador ocultar os 5 de uma vez. A exportação XLSX de Balancete/DRE **não** é afetada — não é "card de indicador" nem "observação", ficou fora do escopo desta rodada.
**Frontend** (`dashboard-contabil.js`): `dcIndicadoresAtual` (`{indicadores, indicadores_ocultos, metadados}`, ver "Banco de indicadores personalizados" abaixo pro terceiro campo — ou `null`) é buscado sob demanda só na primeira vez que a aba "Dashboard" é aberta depois de abrir/criar a apuração (`renderDashboardTab()`, chamado pelo handler de `#dc-tabs`; resetado pra `null` em `renderRevisao()` e após qualquer criação/edição/exclusão de indicador personalizado) — evita um cálculo/consulta extra ao histórico completo da empresa (`_contabil_monta_historico_completo`) toda vez que uma apuração é aberta, já que boa parte das revisões não chega a abrir essa aba. A lista de observações **não** tem fetch próprio — é derivada direto de `apuracaoAtual.contas`/`.linhas_dre`/`.achados`, já carregados no payload principal da apuração. Cada card/linha tem um botão de olho (`.dc-dash-toggle-btn`, reaproveita `.icon-btn` de `components.css` + ícone SVG de olho aberto/fechado inline, sem depender de `pid-icone-escuro.svg`) que chama a action correspondente e atualiza só o item local (sem re-buscar a apuração inteira); desabilitado (`disabled`) quando a apuração já está `concluida`, mesmo espírito de "não pode mais editar observações/achados" já aplicado ao textarea/botões dos outros dois modais desta ferramenta. `pidDcFormatIndicadorMoeda`/`Percentual`/`Indice` espelham os filtros `moeda`/`percentual`/`indice` de `contabil_extras.py` só que em JS — **`null`/`undefined` sempre vira "—", nunca "R$ 0,00"** (mesmo cuidado do relatório: em `IndicadoresFinanceiros`, `None` é "indisponível", ex. EBITDA sem apuração anterior, não zero) — não reaproveita `pidDcFormatMoeda()` já existente no arquivo, que trata `null` como `0` de propósito (usado só pra valores de conta/DRE, que nunca são `None`).
@ -262,7 +275,7 @@ Pedido explícito do usuário — reversão deliberada da decisão de escopo da
### Nova consulta de histórico (`_contabil_monta_historico_completo`, `views.py`)
Diferente de `_contabil_monta_historico` (capada em 2 apurações anteriores, usada só pelas regras de auditoria no `create()`), esta busca **todo** o histórico da empresa — usada só pelo gráfico de evolução e pelo cálculo de Depreciação/Amortização do relatório. Recebe `codigo_empresa`/`competencia_atual` diretamente (não um `CabecalhoExtraido`) porque roda contra uma `ContabilApuracao` já persistida, ao contrário de `_contabil_monta_historico` (que roda durante o `create()`, antes de qualquer coisa existir no banco).
Diferente de `_contabil_monta_historico` (capada em 2 apurações anteriores, usada só pelas regras de auditoria no `create()`), esta busca **todo** o histórico da empresa — usada só pelo cálculo de Depreciação/Amortização do relatório (EBITDA); alimentava também o gráfico de evolução do Resultado Líquido, removido numa rodada seguinte (ver "Relatório 'Gerar Dashboard'" acima). Recebe `codigo_empresa`/`competencia_atual` diretamente (não um `CabecalhoExtraido`) porque roda contra uma `ContabilApuracao` já persistida, ao contrário de `_contabil_monta_historico` (que roda durante o `create()`, antes de qualquer coisa existir no banco).
### Ícone selecionável + flip card no relatório (rodada seguinte)
@ -330,7 +343,7 @@ Pedido explícito do usuário: a Demonstração Mensal (Análise Vertical), até
**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.
**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 na aba (posição revisitada numa rodada seguinte — ver "Seção 'Observações do X' fica ACIMA da tabela" mais abaixo), 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).
@ -392,7 +405,17 @@ Três decisões de escopo confirmadas por `AskUserQuestion` **antes** de impleme
**Relatório "Gerar Dashboard"**: `dashboard()` monta as observações vigentes com `mostrar_ao_cliente=True` uma vez e as usa em dois lugares — penduradas em cada linha da árvore (`_contabil_arvore_contexto()` ganhou os parâmetros opcionais `observacoes_por_chave`/`chave_fn`), pro ícone/painel inline por conta, e nas listas `observacoes_contas`/`observacoes_dre`/`observacoes_analise_vertical`, que agora são listas de `ContabilObservacao` (não mais de contas/linhas). Cada item mostra a assinatura e, quando a observação vem de um mês anterior, a competência em que foi registrada.
**Frontend** (`dashboard-contabil.js`): as observações são carregadas à parte da apuração (`dcCarregarObservacoes()`, chamada ao abrir/criar uma análise) e indexadas por chave natural (`dcObsIndice`), porque não pertencem ao payload da apuração. O editor inline virou uma thread (`dcObsPainelHtml()`/`dcObsItemHtml()`): histórico em cima (autor, data, competência de origem, selos "Histórico"/"Encerrada"/"Aparece ao cliente"/"Interna" e ações de olho, editar, excluir, encerrar/reativar), campo de observação nova embaixo. Um handler único (`dcTrataCliqueObservacao()`) atende as três tabelas e as quatro listas de resumo, e cada mutação refaz o fetch e re-renderiza tudo (`dcRenderObservacoesTudo()`) — a mesma observação pode estar visível em mais de um lugar ao mesmo tempo. O botão da coluna "Observação" ganhou um contador (`.dc-obs-contador`), já que uma conta pode ter várias. Os chips "Todas / Visíveis ao cliente / Internas" (`dcObsFiltro`) existem nas quatro listas e compartilham a mesma variável: filtrar numa aba filtra em todas.
**Seção "Observações do X" fica ACIMA da tabela, não abaixo** (pedido explícito do usuário, rodada seguinte, nas três abas — Balancete/D.R.E./Análise Vertical) — só trocou a ordem dos dois `.dcr-secao` dentro de cada `.dcr-tab-panel` (`dashboard-contabil-relatorio.html`), nenhuma mudança de contexto/dado. **Clicar numa observação rola até a conta/linha que ela referencia, "se houver"**: `dashboard()` calcula, pra cada observação, uma `ancora` (setada direto no objeto Python, não um campo do model — `_com_ancora()`, função local dentro de `dashboard()`) igual ao `data-dcr-id` da linha correspondente na árvore (`conta-{id}`/`linha-{id}`/`av-{id}`), casando pela mesma chave natural que `observacoes_por_tipo` já usa (`mapa_conta_por_chave`/`mapa_dre_por_chave`/`mapa_av_por_chave`, construídos a partir de `contas`/`linhas_dre`/`linhas_analise_vertical` **desta** apuração). `ancora` fica `None` quando a conta/linha não existe mais nesta apuração (observação histórica de uma conta que saiu do plano, por exemplo) — o "se houver" do pedido: a observação continua aparecendo normalmente, só sem virar link. No template, só o `<li>` com `obs.ancora` ganha `data-dcr-obs-ir="{{ obs.ancora }}"` + `role="button" tabindex="0"` + classe `.dcr-obs-item--clicavel` (cursor de ponteiro, contorno de foco); as demais continuam com a mesma aparência de sempre.
`pidDcrArvore(tbodyId)` passou a **devolver** `{ expandeAte, resetar }` em vez de nada — `expandeAte(id)` sobe a cadeia de ancestrais de uma linha (`ancestrais()`, olha `data-dcr-nivel` decrescente a partir do índice da linha) e reabre (`colapsadas[id] = false` + classe `is-expanded` no botão) só os que estiverem colapsados, sem mexer em mais nada da árvore (não é um "expandir tudo", só o caminho necessário até aquela linha) — devolve o `<tr>` já visível, ou `undefined` se `id` não existir. `pidDcrObsResumo(container, arvore)` (nova função) liga o clique/Enter/Espaço num `<li data-dcr-obs-ir>` a `arvore.expandeAte()` + `scrollIntoView({block:"center"})` + um flash de destaque de 1,2s (`.dcr-row-flash`, `@keyframes dcrRowFlash`, anima o `background` do `<td>` a partir do `--dcr-row-bg` que a própria linha já tem, então funciona igual numa linha comum, total ou já destacada). Chamada 3 vezes (uma por `<ul id="dcr-obs-lista-{balancete,dre,av}">`), cada uma recebendo a `arvore` retornada pelo `pidDcrArvore()` da mesma tabela — observação e conta/linha sempre vivem na mesma aba, então nunca precisa trocar de aba pra chegar lá.
Validado de duas formas: (1) ponta a ponta com `Client.force_login()` em transação com rollback forçado — observação com conta real ganhou `data-dcr-obs-ir` correto, observação "órfã" (chave sem conta correspondente nesta apuração) não ganhou âncora nem classe clicável, e as duas seções aparecem antes da tabela no HTML gerado; (2) **clique de verdade num Chromium headless (Playwright)**, contra o relatório real de uma apuração já em produção (empresa `2017`) — confirmou que `expandeAte()` de fato revela a linha e a página rola até ela, sem erro de JS. Essa segunda rodada de teste só aconteceu **depois** de o usuário reportar "não funciona" já em produção: a causa real não era um bug de código, e sim que `dashboard()` (a mudança que calcula `ancora`) mora em `views.py` — o `runserver` do usuário precisa reiniciar (ou o autoreload do Django precisa pegar a mudança) pra passar a computar isso; a reordenação de HTML (mudança só de template) já aparecia sem reiniciar nada, o que mascarou o diagnóstico por um tempo. Lição: uma mudança em `.py` exige reiniciar o `runserver` pra valer pro usuário testar; mudança só em `.html`/`.css` não exige.
**Botão "Limpar formatação" (um por tabela) e "Voltar ao topo"** (pedido explícito do usuário, mesma rodada): `resetar()` (novo, dentro do fechamento de `pidDcrArvore`) devolve `colapsadas`/`destaque`/`historico` pro estado inicial do servidor (mesmo critério de `data-dcr-colapsado-padrao` usado na primeira carga) e limpa qualquer `.dcr-row-flash` que tenha sobrado; `pidDcrObs(tbodyId)` também passou a devolver `{ fecharTudo }`, que fecha todo painel de observação inline aberto na tabela. O botão (`data-dcr-reset="dcr-{balancete,dre,av}-body"`, no cabeçalho de cada seção, ao lado do de exportar XLSX quando existe) chama os dois de uma vez, via dois `dict`s (`dcrArvores`/`dcrObsControles`) que casam o `tbodyId` do atributo com a `arvore`/painel certos — não desfaz o que está **fora** daquela tabela (cada botão só afeta a própria seção). "Voltar ao topo" (`#dcr-scroll-top-btn`, canto inferior direito, sempre presente no DOM) aparece (`.is-visible`, `opacity`/`transform` com `transition` normal — não é um elemento que entra a partir de `hidden`/`display:none`, então não precisa do padrão `animation` do resto do documento) só depois de `window.scrollY` passar de `PID_DCR_SCROLL_TOP_LIMIAR` (320px), e faz `window.scrollTo({top:0, behavior:"smooth"})` ao clicar. Os dois somem em `@media print` (`.dcr-reset-btn`/`.dcr-scroll-top-btn { display: none; }`), mesmo padrão de `.dcr-print-btn`/`.dcr-export-btn`.
**Frontend** (`dashboard-contabil.js`): as observações são carregadas à parte da apuração (`dcCarregarObservacoes()`, chamada ao abrir/criar uma análise) e indexadas por chave natural (`dcObsIndice`), porque não pertencem ao payload da apuração. O editor inline virou uma thread (`dcObsPainelHtml()`/`dcObsItemHtml()`): histórico em cima (autor, data, competência de origem, selos "Histórico"/"Encerrada"/"Editada"/"Aparece ao cliente"/"Interna" e ações de olho, ver histórico de edições (só quando `editada`), editar, encerrar/reativar), campo de observação nova embaixo. **Sem botão de excluir** (removido numa rodada seguinte, pedido explícito do usuário) — a exclusão de uma observação vigente fica limitada ao fluxo de "ocultar das próximas competências" (`encerrar`), nunca um apagar definitivo pela tela; o `DELETE` do backend (`ContabilObservacaoViewSet`, ver "Imutabilidade"/"Endpoints" acima) continua existindo e reachável via API, só não tem mais consumidor no frontend. Um handler único (`dcTrataCliqueObservacao()`) atende as três tabelas e as quatro listas de resumo, e cada mutação refaz o fetch e re-renderiza tudo (`dcRenderObservacoesTudo()`) — a mesma observação pode estar visível em mais de um lugar ao mesmo tempo. O botão da coluna "Observação" ganhou um contador (`.dc-obs-contador`), já que uma conta pode ter várias. Os chips "Todas / Visíveis ao cliente / Internas" (`dcObsFiltro`) existem nas quatro listas e compartilham a mesma variável: filtrar numa aba filtra em todas.
**Histórico de edições de texto** (`ContabilObservacaoEdicao`, migração `0072`, mesma rodada da remoção do botão de excluir): pedido explícito do usuário — sem a opção de excluir, uma edição de texto precisava deixar rastro visível, pra não virar uma forma indireta de "apagar" uma observação importante reescrevendo por cima. Um registro por `PATCH` que muda `texto` de fato (`texto_novo != observacao.texto` antes de salvar, em `ContabilObservacaoViewSet.partial_update()`) — nunca por `mostrar_ao_cliente`/`encerrar`/`reativar`, que não tocam o conteúdo, e nunca quando o texto enviado é igual ao já salvo. `ContabilObservacaoSerializer` ganhou `editada` (`bool(obj.edicoes.all())`) e `edicoes` (lista aninhada, mais recente primeiro no frontend) — os dois só existem nesse serializer (usado pela tela), o relatório HTML pro cliente (`dashboard()`) não os usa, então o selo/histórico nunca aparece lá. `ContabilApuracaoViewSet.observacoes()` ganhou `.prefetch_related("edicoes__editado_por")` pra não gerar uma query por observação. Frontend: selo `.dc-obs-selo--editada` ("Editada") ao lado dos demais, e um botão de relógio (`data-dc-obs-historico`, `PID_DC_ICON_HISTORICO`) que abre `#dc-obs-historico-modal` (`pidDcAbrirHistoricoObservacao()`) — lista texto anterior (riscado) → texto novo, autor e data de cada edição; usa `obs.edicoes` já carregado junto da observação, sem chamada de API própria. Validado ponta a ponta via `Client.force_login()` dentro de uma transação com rollback forçado: criação sem edição (`editada=False`), primeira edição real de texto cria o registro e vira `editada=True`, reenviar o mesmo texto não duplica, alternar `mostrar_ao_cliente` não gera edição, e uma segunda edição real acumula um segundo registro — nada gravado em produção.
**Migração de dados**: a `0071` cria o model, copia cada observação preenchida das três tabelas (autor e data vêm da apuração, a melhor aproximação disponível — o modelo antigo não guardava nada disso por observação; `oculta_no_relatorio` vira `mostrar_ao_cliente` invertido) e só então remove os seis campos antigos. Em produção eram 3 observações (2 de conta, 1 de DRE), todas migradas e conferidas depois de aplicar.

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*`).
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 (thread 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. As observações não morrem com a competência: cada uma fica no histórico daquela empresa + conta, reaparecendo na análise do mês seguinte assinada por quem escreveu e com a data, travada pra edição, até o contador encerrá-la para as próximas execuções. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). Se o arquivo original estava errado/incompleto, o botão "Reprocessar" reanexa um PDF novo pra mesma empresa/competência sem perder nada já registrado — conta/linha sem mudança mantém observação e validação, a que mudou volta pra "não validada" com um alerta, e achado já tratado nunca é apagado ou tem sua justificativa sobrescrita. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, gráfico de evolução, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa.
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, lucro do balancete diferente da DRE, variações atípicas na DRE) — o contador revisa os achados, marca contas/linhas como validadas, registra observações por conta/linha (thread 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. As observações não morrem com a competência: cada uma fica no histórico daquela empresa + conta, reaparecendo na análise do mês seguinte assinada por quem escreveu e com a data, travada pra edição, até o contador encerrá-la para as próximas execuções. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). Se o arquivo original estava errado/incompleto, o botão "Reprocessar" reanexa um PDF novo pra mesma empresa/competência sem perder nada já registrado — conta/linha sem mudança mantém observação e validação, a que mudou volta pra "não validada" com um alerta, e achado já tratado nunca é apagado ou tem sua justificativa sobrescrita. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa — a aba "Resumo" desse relatório também pode ser exportada avulsa em PDF (resumo do fechamento + indicadores + observações, sem o resto do relatório).
**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`/`ContabilLinhaAnaliseVertical`/`ContabilAchado`/`ContabilObservacao` (`portal_api/models.py`).
- `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), `exportacao.py` (Balancete/DRE em XLSX), `resumo_pdf.py` (Resumo do Fechamento + Indicadores + Observações em PDF, avulso).
- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`/`ContabilAchado`/`ContabilObservacao`/`ContabilObservacaoEdicao` (`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).

View File

@ -87,15 +87,6 @@ _RE_PAR_VALOR_VARIACAO = re.compile(
# 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 é
# mais robusto a pequenas variações de nível/indentação entre empresas. O
# texto inclui o prefixo "(=)"/"(-)" tal como o Questor imprime (ver
# `792 - balancete 072026.pdf`).
LINHA_DRE_RECEITA_LIQUIDA = "(=) RECEITA OPERACIONAL LIQUIDA"
LINHA_DRE_CUSTOS_TOTAIS = "(-) CUSTOS TOTAIS"
# Trecho (não o texto exato) da linha "(+/-) Despesas/Receitas Financeiras",
# usado só pelo cálculo de EBIT/EBITDA em dashboard_contabil.indicadores —
# diferente das duas constantes acima, ainda não foi calibrado contra o

View File

@ -8,18 +8,27 @@ não operacional)."""
from __future__ import annotations
from datetime import date
import re
from decimal import Decimal
from .modelos import AchadoDetectado, LinhaBalanceteExtraida, ResultadoExtracao, SnapshotHistorico
from .parser import LINHA_DRE_CUSTOS_TOTAIS, LINHA_DRE_RECEITA_LIQUIDA
# Exclui "NUMERÁRIOS EM TRANSITO"/"... EM TRANSITO" (dinheiro em trânsito,
# conceito diferente de conta transitória/de compensação) do match de
# "TRANSIT" em regra_conta_transitoria_com_saldo, sem trocar o próprio
# critério por "TRANSITOR" — confirmado contra `1751 - Balancete 07.2026.pdf`
# que a fonte embutida corrompe o acento de "TRANSITÓRIA" num caractere
# ilegível (não dá pra recuperar a letra original, diferente do caso mais
# simples de `_normaliza_titulo` em parser.py, que só perde o til e mantém o
# resto do caractere certo) — "TRANSITOR" nunca bateria com essas contas.
# `\b` nas duas pontas garante que só a palavra isolada "TRANSITO" é
# excluída, nunca um prefixo de "TRANSITORIA"/"TRANSITORIO" sem acento.
_RE_TRANSITO_ISOLADO = re.compile(r"\bTRANSITO\b")
SEVERIDADE_ALTA = "alta"
SEVERIDADE_MEDIA = "media"
SEVERIDADE_BAIXA = "baixa"
TOLERANCIA_CENTAVOS = Decimal("0.05")
# Trechos de descrição que, segundo o roteiro de conferência (ITD-FISCO-7513),
# devem ficar zeradas todo mês — ex.: "o saldo de adiantamento de salários e
# empréstimos a funcionários deve estar zerado todos os meses". Deliberadamente
@ -28,23 +37,23 @@ TOLERANCIA_CENTAVOS = Decimal("0.05")
# carregam saldo entre um mês e o pagamento seguinte).
TRECHOS_CONTA_DEVERIA_ZERAR = ["ADIANTAMENTOS DE SALÁRIOS", "ADIANTAMENTO DE SALÁRIOS"]
# Código de classificação fixo pra linha "LUCROS/PREJUÍZOS DO EXERCÍCIO" dentro
# do Patrimônio Líquido — calibrado contra os 2 balancetes reais de referência
# do usuário (`792`/`2017` e `1751`), mesmo padrão de risco já aceito em
# CODIGO_CAIXA/CODIGO_PATRIMONIO_LIQUIDO (indicadores.py). É sintética (tipo
# "S"): agrega "LUCROS DO EXERCÍCIO" (quando a empresa deu lucro) ou "(-)
# PREJUÍZOS DO EXERCÍCIO" (quando deu prejuízo) — por isso a checagem lê o
# saldo desta linha agregadora, não de uma das duas filhas específicas.
CODIGO_LUCRO_PREJUIZO_EXERCICIO = "2.04.13.002"
VARIACAO_LIMIAR_PERCENTUAL = Decimal("0.5") # 50%
VARIACAO_VALOR_MINIMO = Decimal("1000") # ignora variações abaixo disso, mesmo que %-mente grandes
def _mes_consecutivo(competencia_atual: date, competencia_anterior: date) -> bool:
return (competencia_atual.year * 12 + competencia_atual.month) - (
competencia_anterior.year * 12 + competencia_anterior.month
) == 1
def _isola_mes_dre(valor_ytd_atual: Decimal, valor_ytd_anterior: Decimal | None, competencia_atual: date) -> Decimal:
"""A DRE do relatório é acumulada desde janeiro — isola o mês corrente
subtraindo o acumulado do mês anterior, exceto em janeiro (onde o
acumulado já é o próprio mês)."""
if competencia_atual.month == 1 or valor_ytd_anterior is None:
return valor_ytd_atual
return valor_ytd_atual - valor_ytd_anterior
# Piso em pontos percentuais pra regra_variacao_atipica_dre (Análise Vertical)
# — mesmo espírito de VARIACAO_VALOR_MINIMO acima, adaptado pro domínio de
# percentual: evita disparar em saltos %-mente grandes só porque a base de
# comparação já era perto de zero (ex.: 0,05% → 0,2% é 300% de variação
# relativa, mas irrelevante em termos de composição da receita).
VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO = Decimal("1")
def regra_balanceamento_ativo_passivo(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
@ -53,7 +62,7 @@ def regra_balanceamento_ativo_passivo(atual: ResultadoExtracao, historico: list[
if ativo is None or passivo is None:
return []
diferenca = ativo.saldo_atual + passivo.saldo_atual # passivo já vem negativo no relatório
if abs(diferenca) <= TOLERANCIA_CENTAVOS:
if diferenca == 0:
return []
return [
AchadoDetectado(
@ -74,7 +83,7 @@ def regra_debito_credito_divergente(atual: ResultadoExtracao, historico: list[Sn
soma_debito = sum((c.debito for c in raizes), Decimal(0))
soma_credito = sum((c.credito for c in raizes), Decimal(0))
diferenca = soma_debito - soma_credito
if abs(diferenca) <= TOLERANCIA_CENTAVOS:
if diferenca == 0:
return []
return [
AchadoDetectado(
@ -111,7 +120,8 @@ def regra_conta_transitoria_com_saldo(atual: ResultadoExtracao, historico: list[
achados = []
for conta in atual.contas:
descricao_normalizada = conta.descricao.upper()
if ("TRANSIT" in descricao_normalizada) and conta.saldo_atual != 0:
tem_transit = "TRANSIT" in descricao_normalizada and not _RE_TRANSITO_ISOLADO.search(descricao_normalizada)
if tem_transit and conta.saldo_atual != 0:
achados.append(
AchadoDetectado(
regra="conta_transitoria_com_saldo",
@ -179,6 +189,35 @@ def regra_saldo_sinal_invertido(atual: ResultadoExtracao, historico: list[Snapsh
return achados
def regra_lucro_balancete_diverge_dre(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
"""O resultado do exercício precisa ser o mesmo número nas duas
demonstrações — só que, por convenção, o Passivo/PL vem com o sinal
invertido no relatório (ver `regra_balanceamento_ativo_passivo`), então
o saldo da linha do Balancete precisa ser negado antes de comparar com a
última linha da DRE."""
conta_lucro = next((c for c in atual.contas if c.codigo == CODIGO_LUCRO_PREJUIZO_EXERCICIO), None)
if conta_lucro is None or not atual.linhas_dre:
return []
lucro_balancete = -conta_lucro.saldo_atual
lucro_dre = atual.linhas_dre[-1].valor
diferenca = lucro_balancete - lucro_dre
if diferenca == 0:
return []
return [
AchadoDetectado(
regra="lucro_balancete_diverge_dre",
severidade=SEVERIDADE_ALTA,
titulo="Lucro do balancete diferente do lucro da DRE",
mensagem=(
f'Resultado do exercício no Balancete ("{conta_lucro.descricao}", {conta_lucro.codigo}) é de '
f"R$ {lucro_balancete}, mas a DRE aponta R$ {lucro_dre} — diferença de R$ {abs(diferenca)}."
),
codigo_conta=conta_lucro.codigo,
valor_referencia=diferenca,
)
]
def regra_descricao_generica(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
achados = []
for conta in atual.contas:
@ -196,137 +235,55 @@ def regra_descricao_generica(atual: ResultadoExtracao, historico: list[SnapshotH
return achados
def regra_variacao_atipica_saldo(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
if not historico:
return []
anterior = historico[0]
achados = []
for conta in atual.contas:
if conta.tipo != "A":
continue
saldo_anterior = anterior.contas.get(conta.codigo)
if saldo_anterior is None or saldo_anterior == 0:
continue
diferenca = conta.saldo_atual - saldo_anterior
if abs(diferenca) < VARIACAO_VALOR_MINIMO:
continue
variacao_percentual = abs(diferenca) / abs(saldo_anterior)
if variacao_percentual < VARIACAO_LIMIAR_PERCENTUAL:
continue
achados.append(
AchadoDetectado(
regra="variacao_atipica_saldo",
severidade=SEVERIDADE_MEDIA,
titulo="Variação atípica de saldo",
mensagem=(
f'Conta "{conta.descricao}" ({conta.codigo}) variou {variacao_percentual:.0%} em relação a '
f"{anterior.competencia:%m/%Y} (de R$ {saldo_anterior} para R$ {conta.saldo_atual})."
),
codigo_conta=conta.codigo,
valor_referencia=diferenca,
)
)
return achados
def regra_variacao_atipica_dre(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
if len(historico) < 2:
return []
anterior, retrasado = historico[0], historico[1]
if not _mes_consecutivo(anterior.competencia, retrasado.competencia):
return []
"""Usa a própria seção "Demonstração Mensal (Análise Vertical)" do PDF
(quando presente) em vez do histórico de apurações anteriores do Portal
— cada linha já vem com o percentual sobre a Receita Operacional Bruta
em cada um dos últimos meses (calculado pelo próprio Questor, valor já
isolado por mês, não acumulado), então a regra só compara os 2 meses
mais recentes dessa própria tabela. Roda mesmo na primeira apuração de
uma empresa nova, desde que o PDF traga essa seção — não depende de
`historico` (recebido só pra manter a assinatura comum a toda regra)."""
achados = []
for linha in atual.linhas_dre:
valor_anterior_ytd = anterior.linhas_dre.get(linha.descricao)
valor_retrasado_ytd = retrasado.linhas_dre.get(linha.descricao)
if valor_anterior_ytd is None:
for linha in atual.linhas_analise_vertical:
if len(linha.valores) < 2:
continue
mes_atual = _isola_mes_dre(linha.valor, valor_anterior_ytd, atual.cabecalho.periodo_fim)
mes_anterior = _isola_mes_dre(valor_anterior_ytd, valor_retrasado_ytd, anterior.competencia)
if mes_anterior == 0:
continue
diferenca = mes_atual - mes_anterior
if abs(diferenca) < VARIACAO_VALOR_MINIMO:
continue
variacao_percentual = abs(diferenca) / abs(mes_anterior)
if variacao_percentual < VARIACAO_LIMIAR_PERCENTUAL:
mes_anterior, mes_atual = linha.valores[-2], linha.valores[-1]
diferenca_pp = mes_atual.percentual - mes_anterior.percentual
if abs(diferenca_pp) < VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO:
continue
if mes_anterior.percentual != 0:
variacao_relativa = abs(diferenca_pp) / abs(mes_anterior.percentual)
if variacao_relativa < VARIACAO_LIMIAR_PERCENTUAL:
continue
rotulo_anterior = atual.meses_analise_vertical[-2] if len(atual.meses_analise_vertical) >= 2 else "mês anterior"
rotulo_atual = atual.meses_analise_vertical[-1] if atual.meses_analise_vertical else "mês atual"
achados.append(
AchadoDetectado(
regra="variacao_atipica_dre",
severidade=SEVERIDADE_MEDIA,
titulo="Variação atípica na DRE",
mensagem=(
f'Linha "{linha.descricao}" da DRE variou {variacao_percentual:.0%} em relação ao mês anterior '
f"(de R$ {mes_anterior} para R$ {mes_atual})."
f'Linha "{linha.descricao}" da DRE foi de {mes_anterior.percentual:.2f}% da Receita Bruta em '
f"{rotulo_anterior} para {mes_atual.percentual:.2f}% em {rotulo_atual} "
f"({diferenca_pp:+.2f} pontos percentuais)."
),
valor_referencia=diferenca,
valor_referencia=diferenca_pp,
)
)
return achados
def regra_percentual_custo_receita_atipico(
atual: ResultadoExtracao, historico: list[SnapshotHistorico]
) -> list[AchadoDetectado]:
if len(historico) < 2:
return []
anterior, retrasado = historico[0], historico[1]
if not _mes_consecutivo(anterior.competencia, retrasado.competencia):
return []
def _linha(resultado_linhas: dict[str, Decimal], nome: str) -> Decimal | None:
return resultado_linhas.get(nome)
linhas_atual = {l.descricao: l.valor for l in atual.linhas_dre}
receita_atual_ytd = _linha(linhas_atual, LINHA_DRE_RECEITA_LIQUIDA)
custos_atual_ytd = _linha(linhas_atual, LINHA_DRE_CUSTOS_TOTAIS)
receita_anterior_ytd = _linha(anterior.linhas_dre, LINHA_DRE_RECEITA_LIQUIDA)
custos_anterior_ytd = _linha(anterior.linhas_dre, LINHA_DRE_CUSTOS_TOTAIS)
receita_retrasado_ytd = _linha(retrasado.linhas_dre, LINHA_DRE_RECEITA_LIQUIDA)
custos_retrasado_ytd = _linha(retrasado.linhas_dre, LINHA_DRE_CUSTOS_TOTAIS)
if None in (receita_atual_ytd, custos_atual_ytd, receita_anterior_ytd, custos_anterior_ytd):
return []
receita_mes_atual = _isola_mes_dre(receita_atual_ytd, receita_anterior_ytd, atual.cabecalho.periodo_fim)
custos_mes_atual = _isola_mes_dre(custos_atual_ytd, custos_anterior_ytd, atual.cabecalho.periodo_fim)
receita_mes_anterior = _isola_mes_dre(receita_anterior_ytd, receita_retrasado_ytd, anterior.competencia)
custos_mes_anterior = _isola_mes_dre(custos_anterior_ytd, custos_retrasado_ytd, anterior.competencia)
if receita_mes_atual == 0 or receita_mes_anterior == 0:
return []
pct_atual = abs(custos_mes_atual) / abs(receita_mes_atual)
pct_anterior = abs(custos_mes_anterior) / abs(receita_mes_anterior)
if pct_anterior == 0:
return []
variacao_percentual = abs(pct_atual - pct_anterior) / pct_anterior
if variacao_percentual < VARIACAO_LIMIAR_PERCENTUAL:
return []
return [
AchadoDetectado(
regra="percentual_custo_receita_atipico",
severidade=SEVERIDADE_MEDIA,
titulo="Percentual de custo sobre receita fora do padrão da empresa",
mensagem=(
f"O custo representou {pct_atual:.1%} da receita líquida do mês, contra {pct_anterior:.1%} no mês "
f"anterior — variação de {variacao_percentual:.0%} em relação ao padrão histórico desta empresa."
),
valor_referencia=pct_atual - pct_anterior,
)
]
REGRAS = [
regra_balanceamento_ativo_passivo,
regra_debito_credito_divergente,
regra_saldo_negativo_caixa,
regra_saldo_sinal_invertido,
regra_lucro_balancete_diverge_dre,
regra_conta_transitoria_com_saldo,
regra_conta_deveria_zerar,
regra_saldo_sinal_invertido,
regra_descricao_generica,
regra_variacao_atipica_saldo,
regra_variacao_atipica_dre,
regra_percentual_custo_receita_atipico,
]

View File

@ -0,0 +1,344 @@
"""Geração do PDF de "Resumo" do Relatório Contábil — reúne o Resumo do
Fechamento (texto rico do contador), os cards de indicador e as observações
visíveis ao cliente, o mesmo conteúdo da aba "Resumo" do relatório HTML
"Gerar Dashboard" (ver `_contabil_dados_resumo()` em `views.py`). Identidade
visual do escritório (logo cursivo, roxo/dourado do próprio relatório HTML),
mesmo padrão de `custo_contratacao/pdf.py`/`indicadores/recibo.py` — nunca a
marca "P.I.D." do Portal (ver "Logos" no CLAUDE.md raiz)."""
from __future__ import annotations
import base64
import io
from datetime import date
from pathlib import Path
from typing import Any
from xml.sax.saxutils import escape
import bs4
from PIL import Image as PILImage
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import (
Image,
ListFlowable,
ListItem,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
_LOGO_BRANCO_PATH = Path(__file__).resolve().parents[2] / "static" / "img" / "logo-branco.png"
# Mesma paleta roxo/dourado do próprio relatório HTML (dashboard-contabil-relatorio.html,
# `--roxo-escuro`/`--roxo-mais-escuro`/`--dourado`) — não os tokens de tema do
# Portal (que mudam com a cor escolhida pelo usuário logado).
_COR_ROXO_ESCURO = colors.HexColor("#281552")
_COR_DOURADO = colors.HexColor("#b4872a")
_COR_DOURADO_CLARO_BANNER = colors.HexColor("#cbb98c")
_COR_TEXTO = colors.HexColor("#241c33")
_COR_TEXTO_MUTED = colors.HexColor("#6b6478")
_COR_LINHA = colors.HexColor("#e4e0ec")
_COR_ZEBRA = colors.HexColor("#f7f5fb")
_LARGURA_PAGINA, _ALTURA_PAGINA = A4
_MARGEM = 1.8 * cm
_ALTURA_BANNER = 2.6 * cm
_LARGURA_UTIL = _LARGURA_PAGINA - 2 * _MARGEM
def _dimensoes_imagem(caminho: Path, largura_pt: float) -> tuple[float, float]:
with PILImage.open(caminho) as img:
largura_px, altura_px = img.size
return largura_pt, largura_pt * (altura_px / largura_px)
def _ajusta_titulo_banner(canvas: Canvas, texto: str, largura_maxima: float) -> tuple[str, float]:
"""O nome da empresa é livre (vem do cadastro) e o banner tem largura
fixa — reduz o tamanho da fonte até caber numa linha só e, se mesmo no
tamanho mínimo ainda estourar, corta com reticências. Evita o corte
duro que `drawString` faz por padrão (texto simplesmente sai da página)."""
fonte = "Helvetica-Bold"
tamanho = 15.0
tamanho_minimo = 10.0
while tamanho > tamanho_minimo and canvas.stringWidth(texto, fonte, tamanho) > largura_maxima:
tamanho -= 0.5
if canvas.stringWidth(texto, fonte, tamanho) > largura_maxima:
while texto and canvas.stringWidth(texto + "…", fonte, tamanho) > largura_maxima:
texto = texto[:-1]
texto = f"{texto}…"
return texto, tamanho
def _desenha_banner(canvas: Canvas, nome_empresa: str, competencia_label: str, gerado_em: str) -> None:
topo = _ALTURA_PAGINA
canvas.setFillColor(_COR_ROXO_ESCURO)
canvas.rect(0, topo - _ALTURA_BANNER, _LARGURA_PAGINA, _ALTURA_BANNER, stroke=0, fill=1)
canvas.setFillColor(_COR_DOURADO)
canvas.rect(0, topo - _ALTURA_BANNER - 0.09 * cm, _LARGURA_PAGINA, 0.09 * cm, stroke=0, fill=1)
logo_largura, logo_altura = _dimensoes_imagem(_LOGO_BRANCO_PATH, 2.9 * cm)
logo_y = topo - _ALTURA_BANNER / 2 - logo_altura / 2
canvas.drawImage(str(_LOGO_BRANCO_PATH), _MARGEM, logo_y, width=logo_largura, height=logo_altura, mask="auto")
texto_x = _MARGEM + logo_largura + 0.6 * cm
centro_y = topo - _ALTURA_BANNER / 2
largura_disponivel = _LARGURA_PAGINA - _MARGEM - texto_x
titulo, tamanho_titulo = _ajusta_titulo_banner(
canvas, f"Resumo do Fechamento — {nome_empresa}", largura_disponivel
)
canvas.setFillColor(colors.white)
canvas.setFont("Helvetica-Bold", tamanho_titulo)
canvas.drawString(texto_x, centro_y + 0.2 * cm, titulo)
canvas.setFont("Helvetica", 9.5)
canvas.setFillColor(_COR_DOURADO)
canvas.drawString(texto_x, centro_y - 0.28 * cm, f"Competência {competencia_label}")
canvas.setFont("Helvetica", 8)
canvas.setFillColor(_COR_DOURADO_CLARO_BANNER)
canvas.drawString(texto_x, centro_y - 0.62 * cm, f"Gerado em {gerado_em}")
def _desenha_rodape(canvas: Canvas) -> None:
canvas.setStrokeColor(_COR_DOURADO)
canvas.setLineWidth(0.6)
canvas.line(_MARGEM, 1.5 * cm, _LARGURA_PAGINA - _MARGEM, 1.5 * cm)
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(_COR_TEXTO_MUTED)
canvas.drawCentredString(_LARGURA_PAGINA / 2, 1.15 * cm, "De Paula Contadores")
def _pagina(canvas: Canvas, doc: SimpleDocTemplate, nome_empresa: str, competencia_label: str, gerado_em: str) -> None:
canvas.saveState()
_desenha_banner(canvas, nome_empresa, competencia_label, gerado_em)
_desenha_rodape(canvas)
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(_COR_TEXTO_MUTED)
canvas.drawRightString(_LARGURA_PAGINA - _MARGEM, 1.15 * cm, f"Página {doc.page}")
canvas.restoreState()
def _estilos() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet()
return {
"h2": ParagraphStyle(
"resumo_h2", parent=base["Heading2"], textColor=_COR_ROXO_ESCURO, fontSize=13, spaceBefore=14, spaceAfter=8
),
"normal": ParagraphStyle("resumo_normal", parent=base["Normal"], fontSize=9.5, textColor=_COR_TEXTO, leading=13),
"bullet": ParagraphStyle("resumo_bullet", parent=base["Normal"], fontSize=9.5, textColor=_COR_TEXTO, leading=13),
"muted": ParagraphStyle("resumo_muted", parent=base["Normal"], fontSize=8, textColor=_COR_TEXTO_MUTED, leading=11),
"titulo_item": ParagraphStyle(
"resumo_titulo_item",
parent=base["Normal"],
fontSize=9.5,
textColor=_COR_ROXO_ESCURO,
fontName="Helvetica-Bold",
leading=12,
),
"vazio": ParagraphStyle(
"resumo_vazio", parent=base["Normal"], fontSize=9, textColor=_COR_TEXTO_MUTED, fontName="Helvetica-Oblique"
),
}
def _inline_markup(tag: Any) -> str:
"""Reconstrói o innerHTML de um elemento só com a marcação que o
`Paragraph` do reportlab entende nativamente (`<b>`/`<i>`/`<u>`/`<br/>`)
— mesmo allowlist fechado de `RICHTEXT_ALLOWED_TAGS` (`serializers.py`:
p/br/div/b/strong/i/em/u/ul/ol/li/img), então não precisa lidar com
nada fora dele. `<img>` é ignorada aqui de propósito — vira um flowable
`Image` próprio, ver `_extrai_imagens()`."""
partes = []
for filho in tag.contents:
if isinstance(filho, bs4.NavigableString):
partes.append(escape(str(filho)))
elif filho.name == "br":
partes.append("<br/>")
elif filho.name in ("b", "strong"):
partes.append(f"<b>{_inline_markup(filho)}</b>")
elif filho.name in ("i", "em"):
partes.append(f"<i>{_inline_markup(filho)}</i>")
elif filho.name == "u":
partes.append(f"<u>{_inline_markup(filho)}</u>")
elif filho.name == "img":
continue
else:
partes.append(_inline_markup(filho))
return "".join(partes)
def _imagem_flowable(tag_img: Any) -> Image | None:
"""`<img src="data:image/...;base64,...">` — a única forma de imagem que
o editor do Resumo do Fechamento produz (colar/arrastar arquivo, ver
`insertImageFile()` em `dashboard-contabil.js`). Qualquer outra coisa
(src externo, ou decodificação inválida) é ignorada silenciosamente —
o texto ao redor continua aparecendo normalmente."""
src = tag_img.get("src", "")
if not src.startswith("data:image"):
return None
try:
_cabecalho, b64 = src.split(",", 1)
dados = base64.b64decode(b64)
with PILImage.open(io.BytesIO(dados)) as img:
largura_px, altura_px = img.size
largura = min(_LARGURA_UTIL, largura_px * 0.75)
altura = largura * (altura_px / largura_px)
return Image(io.BytesIO(dados), width=largura, height=altura)
except Exception:
return None
def _resumo_fechamento_flowables(html: str, estilos: dict[str, ParagraphStyle]) -> list[Any]:
"""Converte o texto rico do Resumo do Fechamento (já sanitizado por
`nh3`) em flowables do reportlab — parágrafos, listas e imagens
embutidas, na ordem em que aparecem."""
if not html or not html.strip():
return []
soup = bs4.BeautifulSoup(html, "html.parser")
flowables: list[Any] = []
for elemento in soup.contents:
if isinstance(elemento, bs4.NavigableString):
if elemento.strip():
flowables.append(Paragraph(escape(str(elemento)), estilos["normal"]))
continue
nome = elemento.name
if nome in ("p", "div"):
for img_tag in elemento.find_all("img"):
imagem = _imagem_flowable(img_tag)
if imagem:
flowables.append(imagem)
flowables.append(Spacer(1, 0.2 * cm))
texto = _inline_markup(elemento)
if texto.strip():
flowables.append(Paragraph(texto, estilos["normal"]))
flowables.append(Spacer(1, 0.15 * cm))
elif nome in ("ul", "ol"):
itens = [ListItem(Paragraph(_inline_markup(li), estilos["bullet"])) for li in elemento.find_all("li", recursive=False)]
if itens:
flowables.append(ListFlowable(itens, bulletType="bullet" if nome == "ul" else "1", leftIndent=14))
flowables.append(Spacer(1, 0.15 * cm))
elif nome == "img":
imagem = _imagem_flowable(elemento)
if imagem:
flowables.append(imagem)
flowables.append(Spacer(1, 0.2 * cm))
elif nome == "br":
flowables.append(Spacer(1, 0.1 * cm))
else:
texto = _inline_markup(elemento)
if texto.strip():
flowables.append(Paragraph(texto, estilos["normal"]))
flowables.append(Spacer(1, 0.15 * cm))
return flowables
def _tabela_indicadores(grupo: dict[str, Any], estilos: dict[str, ParagraphStyle]) -> Table:
dados = [[grupo["nome"], ""]] + [[card["nome"], card["valor_formatado"]] for card in grupo["cards"]]
tabela = Table(dados, colWidths=[_LARGURA_UTIL - 5 * cm, 5 * cm])
estilo = [
("SPAN", (0, 0), (-1, 0)),
("BACKGROUND", (0, 0), (-1, 0), _COR_DOURADO),
("TEXTCOLOR", (0, 0), (-1, 0), _COR_ROXO_ESCURO),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10.5),
("TOPPADDING", (0, 0), (-1, 0), 7),
("BOTTOMPADDING", (0, 0), (-1, 0), 7),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTNAME", (1, 1), (1, -1), "Helvetica-Bold"),
("FONTSIZE", (0, 1), (-1, -1), 9.5),
("TEXTCOLOR", (0, 1), (-1, -1), _COR_TEXTO),
("ALIGN", (1, 0), (1, -1), "RIGHT"),
("LINEBELOW", (0, 1), (-1, -2), 0.5, _COR_LINHA),
("TOPPADDING", (0, 1), (-1, -1), 5),
("BOTTOMPADDING", (0, 1), (-1, -1), 5),
("BOX", (0, 0), (-1, -1), 0.75, _COR_DOURADO),
]
for i in range(1, len(dados)):
if i % 2 == 0:
estilo.append(("BACKGROUND", (0, i), (-1, i), _COR_ZEBRA))
tabela.setStyle(TableStyle(estilo))
return tabela
_PREFIXO_ALVO = {"conta": "Balancete", "dre": "D.R.E.", "analise_vertical": "Análise Vertical"}
def _texto_com_quebras(texto: str) -> str:
return escape(texto).replace("\n", "<br/>")
def _observacoes_flowables(
apuracao: Any, dados_resumo: dict[str, Any], estilos: dict[str, ParagraphStyle]
) -> list[Any]:
flowables: list[Any] = []
for obs in dados_resumo["observacoes_visiveis"]:
prefixo = _PREFIXO_ALVO.get(obs.alvo_tipo, "")
if obs.alvo_tipo == "conta":
titulo = f"{prefixo} — {obs.alvo_chave} {obs.alvo_rotulo}"
else:
titulo = f"{prefixo} — {obs.alvo_rotulo}"
assinatura = f"{obs.criado_por.nome if obs.criado_por else 'Equipe contábil'} · {obs.criado_em:%d/%m/%Y}"
if obs.competencia_origem != apuracao.competencia:
assinatura += f" · competência {obs.competencia_origem:%m/%Y}"
flowables.append(Paragraph(escape(titulo), estilos["titulo_item"]))
flowables.append(Paragraph(_texto_com_quebras(obs.texto), estilos["normal"]))
flowables.append(Paragraph(escape(assinatura), estilos["muted"]))
flowables.append(Spacer(1, 0.28 * cm))
for achado in dados_resumo["achados_com_observacao"]:
titulo = f"Auditoria — {achado.titulo}"
if achado.tratado_por:
titulo += f" — {achado.tratado_por.nome}"
flowables.append(Paragraph(escape(titulo), estilos["titulo_item"]))
flowables.append(Paragraph(_texto_com_quebras(achado.observacao_contador), estilos["normal"]))
flowables.append(Spacer(1, 0.28 * cm))
if not flowables:
flowables.append(Paragraph("Nenhuma observação registrada nesta análise.", estilos["vazio"]))
return flowables
def gera_pdf_resumo(apuracao: Any, dados_resumo: dict[str, Any], gerado_em: str) -> bytes:
"""`apuracao` é a `ContabilApuracao`; `dados_resumo` é o dict devolvido
por `_contabil_dados_resumo()` (`views.py`) — mesmo conteúdo da aba
"Resumo" do relatório HTML "Gerar Dashboard", sem o resto do relatório
(Balancete/D.R.E./Análise Vertical)."""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
topMargin=_ALTURA_BANNER + 0.8 * cm,
bottomMargin=2.0 * cm,
leftMargin=_MARGEM,
rightMargin=_MARGEM,
title=f"Resumo do Fechamento - {apuracao.nome_empresa}",
)
estilos = _estilos()
story: list[Any] = []
if apuracao.resumo_fechamento:
story.append(Paragraph("Resumo do Fechamento", estilos["h2"]))
story.extend(_resumo_fechamento_flowables(apuracao.resumo_fechamento, estilos))
story.append(Spacer(1, 0.3 * cm))
for grupo in dados_resumo["indicadores_grupos"]:
# Sem Paragraph de título separado aqui — `_tabela_indicadores()` já
# usa o nome do grupo como a própria barra de cabeçalho da tabela
# (mesmo padrão de `_tabela_secao()` em custo_contratacao/pdf.py).
story.append(_tabela_indicadores(grupo, estilos))
story.append(Spacer(1, 0.4 * cm))
story.append(Paragraph("Observações da Análise", estilos["h2"]))
story.extend(_observacoes_flowables(apuracao, dados_resumo, estilos))
def _desenha_pagina(canvas: Canvas, doc: SimpleDocTemplate) -> None:
competencia: date = apuracao.competencia
_pagina(canvas, doc, apuracao.nome_empresa, f"{competencia:%m/%Y}", gerado_em)
doc.build(story, onFirstPage=_desenha_pagina, onLaterPages=_desenha_pagina)
return buffer.getvalue()

View File

@ -168,10 +168,10 @@ class Command(BaseCommand):
permissoes["calendario-individual"]["apps"]["calendario-individual-criar-evento"] = False
# "relatorios" está em SECTORAL_KEYS (habilita todos os apps do módulo
# de uma vez, incluindo "Não Conformidades") — mas essa ferramenta é de
# uma pessoa/área bem mais específica (responsável pela Qualidade) que
# "Relatório Setorial", então nasce restrita: só Integração e Inovação
# tem acesso por padrão, liberado manualmente pra outros perfis depois
# pela tela de Perfis de Acesso.
# uma pessoa/área bem mais específica (responsável pela Qualidade), então
# nasce restrita: só Integração e Inovação tem acesso por padrão,
# liberado manualmente pra outros perfis depois pela tela de Perfis de
# Acesso.
permissoes["relatorios"]["apps"]["nao-conformidades"] = False
# "dashboard-contabil" expõe balancete/DRE completos dos
# clientes (dado financeiro sensível) — mesmo padrão de

View File

@ -0,0 +1,31 @@
# Generated by Django 6.0.7 on 2026-09-14 11:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0071_contabil_observacao_historico'),
]
operations = [
migrations.CreateModel(
name='ContabilObservacaoEdicao',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('texto_anterior', models.TextField(verbose_name='Texto anterior')),
('texto_novo', models.TextField(verbose_name='Texto novo')),
('editado_em', models.DateTimeField(auto_now_add=True, verbose_name='Editado em')),
('editado_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='edicoes_observacoes_contabeis', to=settings.AUTH_USER_MODEL)),
('observacao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edicoes', to='portal_api.contabilobservacao')),
],
options={
'verbose_name': 'Edição de observação do Relatório Contábil',
'verbose_name_plural': 'Edições de observação do Relatório Contábil',
'ordering': ['-editado_em', '-id'],
},
),
]

View File

@ -2356,6 +2356,37 @@ class ContabilObservacao(models.Model):
return self.competencia_origem < competencia
class ContabilObservacaoEdicao(models.Model):
"""Log de cada edição de texto de uma `ContabilObservacao` (pedido
explícito do usuário, depois que o botão de excluir observação foi
removido da tela — "evitar a ocultação de observações importantes" exige
que uma edição de texto deixe rastro visível, não só sobrescreva). Um
registro por `PATCH` que muda `texto` (nunca por `mostrar_ao_cliente`/
`encerrar`/`reativar`, que não mexem no conteúdo). `editada` (usada pro
selo "Editada" na tela) é só `observacao.edicoes.exists()`, sem campo
dedicado.
Só visível dentro do Portal (`ContabilObservacaoSerializer`) — o
relatório HTML gerado pro cliente (`dashboard()`) não usa esse
serializer, então o selo/histórico nunca vaza pra lá."""
observacao = models.ForeignKey(ContabilObservacao, on_delete=models.CASCADE, related_name="edicoes")
texto_anterior = models.TextField("Texto anterior")
texto_novo = models.TextField("Texto novo")
editado_por = models.ForeignKey(
Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="edicoes_observacoes_contabeis"
)
editado_em = models.DateTimeField("Editado em", auto_now_add=True)
class Meta:
verbose_name = "Edição de observação do Relatório Contábil"
verbose_name_plural = "Edições de observação do Relatório Contábil"
ordering = ["-editado_em", "-id"]
def __str__(self) -> str:
return f"Edição de {self.observacao_id} em {self.editado_em:%d/%m/%Y %H:%M}"
class IndicadorContabilDefinicao(models.Model):
"""Um indicador financeiro do Relatório Contábil — todo indicador é um
registro aqui agora, inclusive os antigos 11 "de sistema" (ROA/ROE/

View File

@ -22,6 +22,7 @@ from .models import (
ContabilLinhaAnaliseVertical,
ContabilLinhaDre,
ContabilObservacao,
ContabilObservacaoEdicao,
Departamento,
EmpresaQuestor,
Favorito,
@ -2318,17 +2319,36 @@ class ContabilLinhaAnaliseVerticalSerializer(serializers.ModelSerializer):
]
class ContabilObservacaoEdicaoSerializer(serializers.ModelSerializer):
"""Um item do histórico de edições de texto de uma observação (ver
`ContabilObservacaoEdicao`) — só existe pra alimentar o modal "Histórico
de edições" da tela, nunca é lido separadamente da observação."""
editado_por_nome = serializers.CharField(source="editado_por.nome", read_only=True, default=None)
class Meta:
model = ContabilObservacaoEdicao
fields = ["id", "texto_anterior", "texto_novo", "editado_por_nome", "editado_em"]
read_only_fields = fields
class ContabilObservacaoSerializer(serializers.ModelSerializer):
"""Leitura de uma observação do histórico (ver `ContabilObservacao`).
`historica`/`encerrada` são derivados da competência da apuração que
está sendo exibida, passada em `context["competencia"]` — a mesma
observação é editável na apuração que a criou e somente leitura em
qualquer competência posterior."""
qualquer competência posterior. `editada`/`edicoes` refletem
`ContabilObservacaoEdicao` (nunca vazio depois de qualquer mudança real
de texto) — usados pro selo "Editada" e pro histórico de edições na
tela; o relatório HTML pro cliente não usa este serializer, então esses
dois campos nunca aparecem lá."""
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
encerrada_por_nome = serializers.CharField(source="encerrada_por.nome", read_only=True, default=None)
historica = serializers.SerializerMethodField()
encerrada = serializers.SerializerMethodField()
editada = serializers.SerializerMethodField()
edicoes = ContabilObservacaoEdicaoSerializer(many=True, read_only=True)
class Meta:
model = ContabilObservacao
@ -2349,6 +2369,8 @@ class ContabilObservacaoSerializer(serializers.ModelSerializer):
"encerrada_em",
"historica",
"encerrada",
"editada",
"edicoes",
]
read_only_fields = fields
@ -2359,6 +2381,9 @@ class ContabilObservacaoSerializer(serializers.ModelSerializer):
def get_encerrada(self, obj: ContabilObservacao) -> bool:
return obj.encerrada_em_competencia is not None
def get_editada(self, obj: ContabilObservacao) -> bool:
return len(obj.edicoes.all()) > 0
class ContabilObservacaoCreateSerializer(serializers.Serializer):
"""Entrada de `ContabilObservacaoViewSet.create()` — o cliente informa a

View File

@ -42,6 +42,7 @@ from .dashboard_contabil import formula as dashboard_contabil_formula
from .dashboard_contabil import indicadores as dashboard_contabil_indicadores
from .dashboard_contabil import modelos as dashboard_contabil_modelos
from .dashboard_contabil import pipeline as dashboard_contabil_pipeline
from .dashboard_contabil import resumo_pdf as dashboard_contabil_resumo_pdf
from .templatetags import contabil_extras as dashboard_contabil_extras
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico
@ -58,6 +59,7 @@ from .models import (
ContabilLinhaAnaliseVertical,
ContabilLinhaDre,
ContabilObservacao,
ContabilObservacaoEdicao,
Departamento,
Favorito,
FuncaoTelefonia,
@ -3683,6 +3685,43 @@ def _contabil_agrupa_indicadores_cards(cards: list[dict[str, Any]]) -> list[dict
return [{"nome": nome, "cards": grupos[nome]} for nome in sorted(grupos, key=ordenacao)]
def _contabil_dados_resumo(apuracao: ContabilApuracao) -> dict[str, Any]:
"""Indicadores (já agrupados em cards) + observações visíveis ao cliente
+ achados com observação do contador — o conteúdo da aba "Resumo" do
relatório "Gerar Dashboard", reaproveitado tal e qual pelo PDF do Resumo
(`ContabilApuracaoViewSet.resumo_pdf()`, ver `resumo_pdf.py`). Devolve
`observacoes_visiveis` **sem** separar por `alvo_tipo` nem calcular
`ancora` — isso é específico de cada consumidor (o relatório HTML separa
e pendura `ancora` pra permitir o clique-até-a-conta; o PDF só lista)."""
dados = _contabil_coleta_dados_indicadores(apuracao)
valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados)
cards = _contabil_monta_cards_indicadores(apuracao, valores_indicadores)
achados = list(apuracao.achados.select_related("conta", "tratado_por").order_by("severidade", "id"))
# Observações que o contador marcou pra mostrar ao cliente, já no recorte
# de vigência desta competência (ver `ContabilObservacao`) — inclui as
# herdadas de competências anteriores ainda não encerradas, que é
# justamente o ponto do histórico.
observacoes_visiveis = [
observacao
for observacao in ContabilObservacao.vigentes_para(
apuracao.codigo_empresa, apuracao.competencia
).select_related("criado_por")
if observacao.mostrar_ao_cliente
]
return {
"indicadores_grupos": _contabil_agrupa_indicadores_cards(cards),
"observacoes_visiveis": observacoes_visiveis,
# Cards que o contador escondeu na aba "Dashboard" da tela de revisão
# (ver `indicadores_ocultos()`) já ficam de fora de `cards` acima;
# achado oculto/sem observação não entra no relatório gerado — o
# achado em si continua listado normal na aba Achados da revisão, só
# esta seção de observações é que o omite.
"achados_com_observacao": [
achado for achado in achados if achado.observacao_contador and not achado.oculto_no_relatorio
],
}
# A partir deste nível (equivalente ao 3º segmento de código de classificação,
# ex. "1.01.01") o Balancete/D.R.E. nascem recolhidos, tanto na tela de
# revisão (dashboard-contabil.js) quanto no relatório "Gerar Dashboard" —
@ -4230,8 +4269,10 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
com a conta/linha pela dupla `alvo_tipo`+`alvo_chave`, não por id de
linha (a linha é recriada a cada apuração, a observação não)."""
apuracao = self.get_object()
consulta = ContabilObservacao.vigentes_para(apuracao.codigo_empresa, apuracao.competencia).select_related(
"criado_por", "encerrada_por"
consulta = (
ContabilObservacao.vigentes_para(apuracao.codigo_empresa, apuracao.competencia)
.select_related("criado_por", "encerrada_por")
.prefetch_related("edicoes__editado_por")
)
serializada = ContabilObservacaoSerializer(
consulta, many=True, context={"competencia": apuracao.competencia}
@ -4253,38 +4294,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
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)
valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados)
evolucao = [
{
"competencia": snapshot.competencia.strftime("%m/%Y"),
"resultado_liquido": next(reversed(snapshot.linhas_dre.values()), Decimal(0)),
}
for snapshot in reversed(dados.historico_completo)
]
evolucao.append(
{"competencia": apuracao.competencia.strftime("%m/%Y"), "resultado_liquido": dados.resultado_liquido}
)
# Cards que o contador escondeu na aba "Dashboard" da tela de revisão
# (ver `indicadores_ocultos()`) e observação sem `mostrar_ao_cliente`
# não entram no relatório gerado — a linha/conta/achado em si continua
# listada normal no Balancete/DRE/Observações da revisão, só a seção de
# observações/os cards de indicador deste relatório é que os omitem.
# Observações que o contador marcou pra mostrar ao cliente, já no
# recorte de vigência desta competência (ver `ContabilObservacao`) —
# inclui as herdadas de competências anteriores ainda não encerradas,
# que é justamente o ponto do histórico.
observacoes_visiveis = [
observacao
for observacao in ContabilObservacao.vigentes_para(
apuracao.codigo_empresa, apuracao.competencia
).select_related("criado_por")
if observacao.mostrar_ao_cliente
]
dados_resumo = _contabil_dados_resumo(apuracao)
observacoes_visiveis = dados_resumo["observacoes_visiveis"]
observacoes_por_tipo: dict[str, dict[str, list[ContabilObservacao]]] = {
ContabilObservacao.ALVO_CONTA: {},
ContabilObservacao.ALVO_DRE: {},
@ -4293,11 +4305,32 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
for observacao in observacoes_visiveis:
observacoes_por_tipo[observacao.alvo_tipo].setdefault(observacao.alvo_chave, []).append(observacao)
cards = _contabil_monta_cards_indicadores(apuracao, valores_indicadores)
# `ancora` (setado direto no objeto, não um campo do model) é o mesmo
# `data-dcr-id` da linha correspondente na árvore (`conta-{id}`/
# `linha-{id}`/`av-{id}`) — permite o clique numa observação da seção
# "Observações do Balancete/D.R.E./Análise Vertical" (movida pra cima
# da tabela) rolar até a conta/linha que ela referencia (ver
# `pidDcrObsResumo()` no template). `None` quando a conta/linha não
# existe mais nesta apuração (ex.: observação histórica de uma conta
# que saiu do plano) — a seção mostra o texto normalmente, só sem
# virar link, "se houver" a conta.
def _com_ancora(
observacoes: list[ContabilObservacao], mapa: dict[str, int], prefixo: str
) -> list[ContabilObservacao]:
for observacao in observacoes:
alvo_id = mapa.get(observacao.alvo_chave)
observacao.ancora = f"{prefixo}-{alvo_id}" if alvo_id is not None else None
return observacoes
mapa_conta_por_chave = {ContabilObservacao.chave_conta(c.codigo): c.id for c in contas}
mapa_dre_por_chave = {ContabilObservacao.chave_linha(l.descricao, l.nivel): l.id for l in linhas_dre}
mapa_av_por_chave = {
ContabilObservacao.chave_linha(l.descricao, l.nivel): l.id for l in linhas_analise_vertical
}
contexto = {
"apuracao": apuracao,
"indicadores_grupos": _contabil_agrupa_indicadores_cards(cards),
"evolucao": evolucao,
"indicadores_grupos": dados_resumo["indicadores_grupos"],
"contas": _contabil_arvore_contexto(
contas,
"conta",
@ -4323,20 +4356,26 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
observacoes_por_tipo[ContabilObservacao.ALVO_ANALISE_VERTICAL],
lambda l: ContabilObservacao.chave_linha(l.descricao, l.nivel),
),
"observacoes_contas": [
observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_CONTA
],
"observacoes_dre": [
observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_DRE
],
"observacoes_analise_vertical": [
observacao
for observacao in observacoes_visiveis
if observacao.alvo_tipo == ContabilObservacao.ALVO_ANALISE_VERTICAL
],
"achados_com_observacao": [
achado for achado in achados if achado.observacao_contador and not achado.oculto_no_relatorio
],
"observacoes_contas": _com_ancora(
[observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_CONTA],
mapa_conta_por_chave,
"conta",
),
"observacoes_dre": _com_ancora(
[observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_DRE],
mapa_dre_por_chave,
"linha",
),
"observacoes_analise_vertical": _com_ancora(
[
observacao
for observacao in observacoes_visiveis
if observacao.alvo_tipo == ContabilObservacao.ALVO_ANALISE_VERTICAL
],
mapa_av_por_chave,
"av",
),
"achados_com_observacao": dados_resumo["achados_com_observacao"],
"gerado_em": timezone.now(),
}
html = render_to_string("dashboard-contabil-relatorio.html", contexto)
@ -4544,6 +4583,23 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
response["Content-Disposition"] = f'attachment; filename="{nome_arquivo}"'
return response
@action(detail=True, methods=["get"], url_path="resumo-pdf")
def resumo_pdf(self, request: Request, pk: str | None = None) -> HttpResponse:
"""PDF com o Resumo do Fechamento + Indicadores + Observações — o
mesmo conteúdo da aba "Resumo" do relatório "Gerar Dashboard" (ver
`_contabil_dados_resumo()`), avulso, sem o resto do relatório
(Balancete/D.R.E./Análise Vertical). GET, mesmo motivo de
`dashboard()` acima (abre via `window.open`, não fetch+blob)."""
apuracao = self.get_object()
dados_resumo = _contabil_dados_resumo(apuracao)
pdf_bytes = dashboard_contabil_resumo_pdf.gera_pdf_resumo(
apuracao, dados_resumo, timezone.localtime().strftime("%d/%m/%Y %H:%M")
)
nome_arquivo = f"resumo-{apuracao.codigo_empresa}-{apuracao.competencia:%m-%Y}.pdf"
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{nome_arquivo}"'
return response
def _contabil_garante_em_revisao(apuracao: ContabilApuracao) -> None:
if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA:
@ -4621,7 +4677,10 @@ class ContabilObservacaoViewSet(viewsets.ModelViewSet):
autor/data ficam congelados) e `mostrar_ao_cliente` sempre — decisão
confirmada com o usuário: o bloqueio protege o registro histórico, mas
mostrar ou não ao cliente é uma decisão editorial de cada relatório,
inclusive de uma competência posterior.
inclusive de uma competência posterior. Toda mudança real de `texto`
grava um `ContabilObservacaoEdicao` (texto anterior/novo, autor, data)
— é o que alimenta o selo "Editada" e o histórico de edições na tela,
já que não existe mais botão de excluir a observação.
- `destroy` segue a mesma regra do texto (só apaga o que ainda é da
competência aberta; histórico não se apaga, se encerra).
- `encerrar`/`reativar` ligam/desligam `encerrada_em_competencia` — é o
@ -4691,7 +4750,15 @@ class ContabilObservacaoViewSet(viewsets.ModelViewSet):
campos: list[str] = []
if "texto" in dados:
self._garante_texto_editavel(observacao)
observacao.texto = dados["texto"].strip()
texto_novo = dados["texto"].strip()
if texto_novo != observacao.texto:
ContabilObservacaoEdicao.objects.create(
observacao=observacao,
texto_anterior=observacao.texto,
texto_novo=texto_novo,
editado_por=request.user,
)
observacao.texto = texto_novo
campos.append("texto")
if "mostrar_ao_cliente" in dados:
observacao.mostrar_ao_cliente = dados["mostrar_ao_cliente"]

2
prd.md
View File

@ -32,7 +32,7 @@ Colaboradores do escritório, autenticados por login/senha (sessão Django). O a
- **Importação de Plano de Saúde** — importa o faturamento de uma operadora de plano de saúde/odontológico e gera o arquivo de lançamento no leiaute do Questor, com auditoria do que não casou automaticamente.
### Reservados no menu, sem tela própria ainda
- **Portais** (Portal do Cliente, Portal Fiscal), **Relatórios** (Relatório Setorial), **Relatórios Gerenciais** (Visão Diretoria, Visão Gerencial), **Integrações** (Questor), **Auditorias** (Consultoria Tributária, Fisco/Contábil) — já existem no catálogo de permissões (menu, controle de acesso), mas ainda não têm funcionalidade implementada.
- **Portais** (Portal do Cliente, Portal Fiscal), **Relatórios** (Indicadores), **Relatórios Gerenciais** (Visão Diretoria, Visão Gerencial), **Integrações** (Questor), **Auditorias** (Consultoria Tributária, Fisco/Contábil) — já existem no catálogo de permissões (menu, controle de acesso), mas ainda não têm funcionalidade implementada. Dentro de **Relatórios**, "Qualidade" (Não Conformidades) e "Contabilidade" (Relatório Contábil) já têm funcionalidade própria — não são placeholder.
## Fora de escopo, por decisão explícita (não implementar sem confirmar de novo)

View File

@ -995,6 +995,45 @@
color: var(--teal);
}
.dc-obs-selo--editada {
background: rgba(var(--coral-rgb), 0.18);
color: var(--coral);
}
.dc-obs-historico-lista {
display: flex;
flex-direction: column;
gap: var(--space-3);
max-height: 50vh;
overflow-y: auto;
}
.dc-obs-historico-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border-radius: var(--radius-md);
background: var(--card-bg-hover);
}
.dc-obs-historico-item__meta {
font-size: 0.75rem;
color: var(--text-muted);
}
.dc-obs-historico-item__texto {
margin: 0;
font-size: 0.85rem;
white-space: pre-wrap;
}
.dc-obs-historico-item__texto--anterior {
color: var(--text-secondary);
text-decoration: line-through;
text-decoration-color: rgba(var(--danger-rgb), 0.6);
}
.dc-obs-nova {
display: flex;
flex-direction: column;

View File

@ -7,25 +7,24 @@ const PID_DC_ACHADO_STATUS_LABELS = { pendente: "Pendente", tratado: "Tratado",
const PID_DC_SEVERIDADE_LISTA = ["alta", "media", "baixa"];
const PID_DC_SEVERIDADE_CORES = { alta: "var(--danger)", media: "var(--gold)", baixa: "rgb(var(--slate-rgb))" };
// Mesmas 10 regras/chaves de regras.py (REGRAS) — enumeradas aqui pra sempre
// mostrar as 10 categorias no resumo, mesmo as que não geraram achado nesta
// Mesmas 9 regras/chaves de regras.py (REGRAS) — enumeradas aqui pra sempre
// mostrar as 9 categorias no resumo, mesmo as que não geraram achado nesta
// apuração (mesmo espírito do "Nenhum registro encontrado" do sistema externo
// que inspirou esta visão).
const PID_DC_REGRAS = [
{ chave: "balanceamento_ativo_passivo", label: "Balanceamento Ativo x Passivo" },
{ chave: "debito_credito_divergente", label: "Débito x Crédito" },
{ chave: "saldo_negativo_caixa", label: "Caixa Negativo" },
{ chave: "saldo_sinal_invertido", label: "Sinal de Saldo Invertido" },
{ chave: "lucro_balancete_diverge_dre", label: "Lucro Balancete x DRE" },
{ chave: "conta_transitoria_com_saldo", label: "Contas Transitórias" },
{ chave: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" },
{ chave: "saldo_sinal_invertido", label: "Sinal de Saldo Invertido" },
{ chave: "descricao_generica", label: "Descrição Genérica" },
{ chave: "variacao_atipica_saldo", label: "Variação Atípica de Saldo" },
{ chave: "variacao_atipica_dre", label: "Variação Atípica na DRE" },
{ chave: "percentual_custo_receita_atipico", label: "Percentual Custo/Receita" },
];
// Agrupamento temático das 10 regras acima pro resumo da aba Observações —
// evita 10 cards soltos no grid (poluído visualmente com uma tabela por
// Agrupamento temático das 9 regras acima pro resumo da aba Observações —
// evita 9 cards soltos no grid (poluído visualmente com uma tabela por
// conta em cada um); cada grupo vira 1 card com as regras dele listadas por
// linha (label + contagem), clique numa linha com contagem > 0 continua
// isolando achado.regra na lista abaixo, igual antes.
@ -37,6 +36,7 @@ const PID_DC_GRUPOS = [
"debito_credito_divergente",
"saldo_negativo_caixa",
"saldo_sinal_invertido",
"lucro_balancete_diverge_dre",
],
},
{
@ -45,7 +45,7 @@ const PID_DC_GRUPOS = [
},
{
label: "Variações e Indicadores",
regras: ["variacao_atipica_saldo", "variacao_atipica_dre", "percentual_custo_receita_atipico"],
regras: ["variacao_atipica_dre"],
},
];
@ -116,6 +116,8 @@ const PID_DC_ICON_EDITAR =
'<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="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
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>';
const PID_DC_ICON_HISTORICO =
'<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 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>';
// Percentual da Análise Vertical já vem "pronto" do PDF (ex. "-7.33" quer
// dizer -7,33%, não -733%) — diferente de pidDcFormatIndicadorPercentual
@ -258,10 +260,6 @@ async function pidAtualizarObservacaoContabil(id, payload) {
return pidApiRequest(`/contabil-observacoes/${id}/`, { method: "PATCH", body: payload });
}
async function pidExcluirObservacaoContabil(id) {
return pidApiRequest(`/contabil-observacoes/${id}/`, { method: "DELETE" });
}
// "Ocultar das próximas execuções": continua visível nesta competência e
// some a partir da seguinte. `reativar` desfaz.
async function pidEncerrarObservacaoContabil(id, apuracaoId) {
@ -1471,6 +1469,7 @@ document.addEventListener("DOMContentLoaded", async () => {
`<span class="dc-obs-selo dc-obs-selo--encerrada">Encerrada em ${pidDcFormatCompetencia(obs.encerrada_em_competencia)}</span>`
);
}
if (obs.editada) selos.push('<span class="dc-obs-selo dc-obs-selo--editada">Editada</span>');
selos.push(
obs.mostrar_ao_cliente
? '<span class="dc-obs-selo dc-obs-selo--cliente">Aparece ao cliente</span>'
@ -1480,10 +1479,14 @@ document.addEventListener("DOMContentLoaded", async () => {
const acoes = [
`<button type="button" class="icon-btn dc-dash-toggle-btn" data-dc-obs-visibilidade="${obs.id}" title="${obs.mostrar_ao_cliente ? "Ocultar do relatório do cliente" : "Mostrar no relatório do cliente"}" aria-label="${obs.mostrar_ao_cliente ? "Ocultar do relatório" : "Mostrar no relatório"}" ${concluida ? "disabled" : ""}>${obs.mostrar_ao_cliente ? PID_DC_ICON_OLHO_ABERTO : PID_DC_ICON_OLHO_FECHADO}</button>`,
];
if (obs.editada) {
acoes.push(
`<button type="button" class="icon-btn" data-dc-obs-historico="${obs.id}" title="Ver histórico de edições" aria-label="Ver histórico de edições">${PID_DC_ICON_HISTORICO}</button>`
);
}
if (podeEditarTexto) {
acoes.push(
`<button type="button" class="icon-btn" data-dc-obs-editar="${obs.id}" title="Editar esta observação" aria-label="Editar observação">${PID_DC_ICON_EDITAR}</button>`,
`<button type="button" class="icon-btn" data-dc-obs-excluir="${obs.id}" title="Excluir esta observação" aria-label="Excluir observação">${PID_DC_ICON_LIXEIRA}</button>`
`<button type="button" class="icon-btn" data-dc-obs-editar="${obs.id}" title="Editar esta observação" aria-label="Editar observação">${PID_DC_ICON_EDITAR}</button>`
);
}
if (!concluida) {
@ -1518,6 +1521,28 @@ document.addEventListener("DOMContentLoaded", async () => {
`;
}
// Modal "Histórico de edições" — só existe pra observações com `editada`
// (ver `ContabilObservacaoEdicao` no backend). `obs.edicoes` já vem
// carregado junto da observação (`ContabilObservacaoSerializer`), então
// não faz nenhuma chamada de API própria.
function pidDcAbrirHistoricoObservacao(obs) {
const edicoes = [...(obs.edicoes || [])].sort((a, b) => new Date(b.editado_em) - new Date(a.editado_em));
const lista = edicoes
.map(
(edicao) => `
<div class="dc-obs-historico-item">
<span class="dc-obs-historico-item__meta">${pidDcEscapeHtml(edicao.editado_por_nome || "Usuário removido")} · ${pidDcFormatData(edicao.editado_em)}</span>
<p class="dc-obs-historico-item__texto dc-obs-historico-item__texto--anterior">${pidDcEscapeHtml(edicao.texto_anterior)}</p>
<p class="dc-obs-historico-item__texto">${pidDcEscapeHtml(edicao.texto_novo)}</p>
</div>
`
)
.join("");
document.getElementById("dc-obs-historico-lista").innerHTML =
lista || '<p class="dc-obs-thread__vazio">Nenhuma edição registrada.</p>';
document.getElementById("dc-obs-historico-modal").hidden = false;
}
// `<tr>` extra logo abaixo da conta/linha (mesma `.dc-obs-edit-row` de
// antes): a thread do histórico em cima, o campo de observação nova
// embaixo. Uma observação nunca é sobrescrita — escrever de novo cria mais
@ -1602,6 +1627,14 @@ document.addEventListener("DOMContentLoaded", async () => {
return true;
}
const historicoBtn = event.target.closest("[data-dc-obs-historico]");
if (historicoBtn) {
const id = Number(historicoBtn.getAttribute("data-dc-obs-historico"));
const obs = dcObservacoes.find((o) => o.id === id);
if (obs) pidDcAbrirHistoricoObservacao(obs);
return true;
}
const editarBtn = event.target.closest("[data-dc-obs-editar]");
if (editarBtn) {
dcObsEditandoId = Number(editarBtn.getAttribute("data-dc-obs-editar"));
@ -1623,7 +1656,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const texto = document.getElementById(`dc-obs-edit-texto-${id}`).value.trim();
erroEl.textContent = "";
if (!texto) {
erroEl.textContent = "A observação não pode ficar vazia. Exclua-a se não quiser mais registrá-la.";
erroEl.textContent = "A observação não pode ficar vazia. Oculte-a se não quiser mais exibi-la nas próximas competências.";
return true;
}
editSalvarBtn.disabled = true;
@ -1639,20 +1672,6 @@ document.addEventListener("DOMContentLoaded", async () => {
return true;
}
const excluirBtn = event.target.closest("[data-dc-obs-excluir]");
if (excluirBtn) {
const id = Number(excluirBtn.getAttribute("data-dc-obs-excluir"));
if (!(await pidConfirm("Excluir esta observação?", { perigoso: true }))) return true;
try {
await pidExcluirObservacaoContabil(id);
await dcCarregarObservacoes();
dcRenderObservacoesTudo();
} catch (e) {
await pidAlert(e.message);
}
return true;
}
const encerrarBtn = event.target.closest("[data-dc-obs-encerrar]");
if (encerrarBtn) {
const id = Number(encerrarBtn.getAttribute("data-dc-obs-encerrar"));
@ -2356,6 +2375,15 @@ document.addEventListener("DOMContentLoaded", async () => {
}
});
document.getElementById("dc-obs-historico-modal-fechar-btn").addEventListener("click", () => {
document.getElementById("dc-obs-historico-modal").hidden = true;
});
document.getElementById("dc-obs-historico-modal").addEventListener("click", (event) => {
if (event.target.id === "dc-obs-historico-modal") {
document.getElementById("dc-obs-historico-modal").hidden = true;
}
});
// ---- Modal "Novo/Editar Indicador" ----
// Opções de ícone do card no relatório "Gerar Dashboard" — cópia irmã de

View File

@ -111,7 +111,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -112,7 +112,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -111,7 +111,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -9,7 +9,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
:root {
--roxo-escuro: #3d2178;
@ -71,6 +70,14 @@
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
/* Clicar numa observação (seção acima da tabela) rola até a linha
referenciada e pisca esse destaque uma vez, pra deixar claro qual
linha é aquela — reinicia a cada clique via remove+reflow+add da
classe (ver pidDcrObsResumo() mais abaixo), não `transition`. */
@keyframes dcrRowFlash {
0%, 100% { background: var(--dcr-row-bg, transparent); }
30% { background: var(--dourado-claro); }
}
.dcr-page { max-width: 1140px; margin: 0 auto; padding: 0 24px 56px; }
@ -144,6 +151,12 @@
(mesmo truque do .dcr-print-btn dentro do header). Mais discreto que o
botão de imprimir (outline, não preenchido) por ser uma ação
secundária por seção, não a ação principal do documento. */
/* "Exportar PDF" da aba Resumo (ver `resumo_pdf()` em views.py) fica
FORA de qualquer `<h2>` — a aba tem várias seções (Resumo do
Fechamento é condicional, indicadores é um loop de grupos), sem um
único cabeçalho pra pendurar o botão — por isso essa barra dedicada
no topo da aba, reaproveitando `.dcr-export-btn` (mesmo visual). */
.dcr-resumo-toolbar { display: flex; justify-content: flex-end; margin-bottom: 16px; }
.dcr-export-btn {
margin-left: auto;
display: inline-flex;
@ -162,6 +175,61 @@
.dcr-export-btn:hover { background: var(--dourado-claro); transform: translateY(-1px); }
.dcr-export-btn:active { transform: translateY(0); }
/* Botão "Limpar formatação" — mesmo estilo do de exportar (outline
discreto), mas em roxo pra não parecer uma segunda ação de exportação.
Fica ANTES do de exportar dentro do `<h2>` (quando os dois existem, só
Balancete/D.R.E.) e é quem carrega o `margin-left:auto` que empurra os
dois pro fim da linha — a Análise Vertical não tem exportar XLSX, então
esse botão sozinho já resolve sem precisar de wrapper. */
.dcr-reset-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 999px;
border: 1.5px solid var(--roxo-medio);
color: var(--roxo-escuro);
background: #fff;
font-family: inherit;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
transition: background 160ms ease, transform 160ms ease;
}
.dcr-reset-btn:hover { background: var(--roxo-claro); transform: translateY(-1px); }
.dcr-reset-btn:active { transform: translateY(0); }
/* Botão flutuante "Voltar ao topo" — canto inferior direito, sempre
presente no DOM (nunca via `hidden`, então `opacity`/`transform` com
`transition` normal já funciona, diferente do resto do documento que
usa `animation` pra reiniciar a partir de `display:none`/`hidden`).
Aparece só depois de rolar um pouco (ver `pidDcrScrollTop()`), pra não
poluir a tela logo no topo do relatório. */
.dcr-scroll-top-btn {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 20;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, var(--roxo-medio), var(--roxo-escuro));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 8px 20px -6px rgba(61, 33, 120, 0.55);
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 200ms ease, transform 200ms ease, box-shadow 160ms ease;
}
.dcr-scroll-top-btn.is-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
.dcr-scroll-top-btn:hover { box-shadow: 0 10px 24px -6px rgba(61, 33, 120, 0.7); }
/* ---- Abas ---- */
.dcr-tabs {
position: relative;
@ -320,16 +388,6 @@
white-space: pre-wrap;
}
/* ---- Gráfico ---- */
.dcr-chart-wrap {
background: #fff;
border: 1px solid var(--borda);
border-radius: 14px;
padding: 20px;
box-shadow: var(--sombra);
animation: dcrFadeSlideUp 460ms ease 120ms both;
}
/* ---- Tabelas ---- */
.dcr-tabela-wrap {
background: #fff;
@ -397,6 +455,7 @@
.dcr-tabela tbody tr.dcr-destaque {
--dcr-row-bg: #fff;
}
.dcr-tabela tbody tr.dcr-row-flash td { animation: dcrRowFlash 1200ms ease; }
.dcr-desc-cell { display: flex; align-items: center; gap: 7px; }
.dcr-toggle {
@ -473,6 +532,13 @@
transition: box-shadow 160ms ease, transform 160ms ease;
}
.dcr-obs-item:hover { transform: translateX(2px); box-shadow: var(--sombra-hover); }
/* Só as observações com `ancora` (a conta/linha ainda existe nesta
apuração, ver `_com_ancora()` em views.py) ficam clicáveis — as sem
conta associada continuam com o mesmo hover sutil de sempre, sem
cursor de ponteiro nem contorno de foco, já que não têm pra onde ir. */
.dcr-obs-item--clicavel { cursor: pointer; }
.dcr-obs-item--clicavel:hover { border-color: var(--roxo-medio); }
.dcr-obs-item--clicavel:focus-visible { outline: 2px solid var(--roxo-medio); outline-offset: 2px; }
.dcr-obs-item__icone {
flex-shrink: 0;
width: 30px;
@ -525,6 +591,9 @@
body { background: #fff; }
.dcr-print-btn { display: none; }
.dcr-export-btn { display: none; }
.dcr-resumo-toolbar { display: none; }
.dcr-reset-btn { display: none; }
.dcr-scroll-top-btn { display: none; }
.dcr-header { margin: 0 0 24px; }
.dcr-tabs { display: none; }
.dcr-tab-panel { display: block !important; }
@ -566,10 +635,37 @@
</nav>
<div class="dcr-tab-panel" data-dcr-panel="balancete">
<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 do Balancete
</h2>
{% if observacoes_contas %}
<ul class="dcr-obs-lista" id="dcr-obs-lista-balancete">
{% for obs in observacoes_contas %}
<li class="dcr-obs-item{% if obs.ancora %} dcr-obs-item--clicavel{% endif %}"{% if obs.ancora %} data-dcr-obs-ir="{{ obs.ancora }}" role="button" tabindex="0"{% endif %} 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"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></div>
<div>
<div class="dcr-obs-item__titulo">{{ obs.alvo_chave }} — {{ obs.alvo_rotulo }}</div>
<div class="dcr-obs-item__texto">{{ obs.texto }}</div>
<div class="dcr-obs-item__assinatura">{{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="dcr-vazio">Nenhuma observação registrada no Balancete.</p>
{% endif %}
</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"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
Balancete
<button type="button" class="dcr-reset-btn" data-dcr-reset="dcr-balancete-body">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>
Limpar formatação
</button>
<a class="dcr-export-btn" href="/api/contabil-apuracoes/{{ apuracao.id }}/exportar-xlsx/?parte=balancete">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Exportar XLSX
@ -633,19 +729,21 @@
</table>
</div>
</div>
</div>
<div class="dcr-tab-panel" data-dcr-panel="dre" 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="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 do Balancete
Observações da D.R.E.
</h2>
{% if observacoes_contas %}
<ul class="dcr-obs-lista">
{% for obs in observacoes_contas %}
<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"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></div>
{% if observacoes_dre %}
<ul class="dcr-obs-lista" id="dcr-obs-lista-dre">
{% for obs in observacoes_dre %}
<li class="dcr-obs-item{% if obs.ancora %} dcr-obs-item--clicavel{% endif %}"{% if obs.ancora %} data-dcr-obs-ir="{{ obs.ancora }}" role="button" tabindex="0"{% endif %} 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="M4 19V5a2 2 0 0 1 2-2h8l6 6v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M14 3v6h6"/></svg></div>
<div>
<div class="dcr-obs-item__titulo">{{ obs.alvo_chave }} — {{ obs.alvo_rotulo }}</div>
<div class="dcr-obs-item__titulo">{{ obs.alvo_rotulo }}</div>
<div class="dcr-obs-item__texto">{{ obs.texto }}</div>
<div class="dcr-obs-item__assinatura">{{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}</div>
</div>
@ -653,16 +751,18 @@
{% endfor %}
</ul>
{% else %}
<p class="dcr-vazio">Nenhuma observação registrada no Balancete.</p>
<p class="dcr-vazio">Nenhuma observação registrada na D.R.E.</p>
{% endif %}
</div>
</div>
<div class="dcr-tab-panel" data-dcr-panel="dre" 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="M4 19V5a2 2 0 0 1 2-2h8l6 6v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M14 3v6h6M9 13h6M9 17h6"/></svg>
D.R.E. — Demonstração do Resultado do Exercício
<button type="button" class="dcr-reset-btn" data-dcr-reset="dcr-dre-body">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>
Limpar formatação
</button>
<a class="dcr-export-btn" href="/api/contabil-apuracoes/{{ apuracao.id }}/exportar-xlsx/?parte=dre">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Exportar XLSX
@ -712,17 +812,20 @@
</table>
</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="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 D.R.E.
Observações da Análise Vertical
</h2>
{% if observacoes_dre %}
<ul class="dcr-obs-lista">
{% for obs in observacoes_dre %}
<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="M4 19V5a2 2 0 0 1 2-2h8l6 6v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M14 3v6h6"/></svg></div>
{% if observacoes_analise_vertical %}
<ul class="dcr-obs-lista" id="dcr-obs-lista-av">
{% for obs in observacoes_analise_vertical %}
<li class="dcr-obs-item{% if obs.ancora %} dcr-obs-item--clicavel{% endif %}"{% if obs.ancora %} data-dcr-obs-ir="{{ obs.ancora }}" role="button" tabindex="0"{% endif %} 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">{{ obs.alvo_rotulo }}</div>
<div class="dcr-obs-item__texto">{{ obs.texto }}</div>
@ -732,17 +835,18 @@
{% endfor %}
</ul>
{% else %}
<p class="dcr-vazio">Nenhuma observação registrada na D.R.E.</p>
<p class="dcr-vazio">Nenhuma observação registrada na Análise Vertical.</p>
{% endif %}
</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)
<button type="button" class="dcr-reset-btn" data-dcr-reset="dcr-av-body">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>
Limpar formatação
</button>
</h2>
<div class="dcr-tabela-wrap">
<table class="dcr-tabela">
@ -800,33 +904,16 @@
</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 obs 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">{{ obs.alvo_rotulo }}</div>
<div class="dcr-obs-item__texto">{{ obs.texto }}</div>
<div class="dcr-obs-item__assinatura">{{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}</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-resumo-toolbar">
<a class="dcr-export-btn" href="/api/contabil-apuracoes/{{ apuracao.id }}/resumo-pdf/">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M12 18v-6M9 15l3 3 3-3"/></svg>
Exportar PDF
</a>
</div>
{% if apuracao.resumo_fechamento %}
<div class="dcr-secao dcr-resumo-fechamento">
<h2>
@ -871,20 +958,6 @@
{% endfor %}
</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="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>
Evolução do Resultado Líquido
</h2>
<div class="dcr-chart-wrap">
{% if evolucao|length > 1 %}
<canvas id="dcr-grafico-evolucao" height="80"></canvas>
{% else %}
<p class="dcr-vazio">Ainda não há histórico suficiente desta empresa no Portal para um gráfico de evolução.</p>
{% endif %}
</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>
@ -941,14 +1014,17 @@
<p class="dcr-footer">Gerado pelo Portal Interno da De Paula em {{ gerado_em|date:"d/m/Y H:i" }}.</p>
</div>
{{ evolucao|json_script:"dcr-evolucao-data" }}
<button type="button" class="dcr-scroll-top-btn" id="dcr-scroll-top-btn" aria-label="Voltar ao topo" title="Voltar ao topo">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>
</button>
<script>
(function () {
// ---- Abas (Balancete / D.R.E. / Indicadores), com indicador deslizante ----
var tabs = Array.prototype.slice.call(document.querySelectorAll("[data-dcr-tab]"));
var paineis = document.querySelectorAll("[data-dcr-panel]");
var indicador = document.getElementById("dcr-tabs-indicator");
var graficoInicializado = false;
var cardsInicializados = false;
function posicionaIndicador(tab) {
if (!indicador || !tab) return;
@ -961,15 +1037,12 @@
tabs.forEach(function (t) { t.classList.toggle("is-active", t === tab); });
paineis.forEach(function (p) { p.hidden = p.getAttribute("data-dcr-panel") !== alvo; });
posicionaIndicador(tab);
// O <canvas> do gráfico e os cards de indicador começam dentro de
// uma aba escondida (Balancete é a aba inicial) — Chart.js não
// desenha direito num canvas com largura/altura zero, e a contagem
// animada dos cards não faz sentido rodar enquanto está invisível,
// então os dois só disparam na primeira vez que a aba "Indicadores"
// é aberta de fato.
if (alvo === "indicadores" && !graficoInicializado) {
graficoInicializado = true;
inicializaGrafico();
// Os cards de indicador começam dentro de uma aba escondida
// (Balancete é a aba inicial) — a contagem animada não faz sentido
// rodar enquanto está invisível, então só dispara na primeira vez
// que a aba "Resumo" é aberta de fato.
if (alvo === "indicadores" && !cardsInicializados) {
cardsInicializados = true;
pidDcrAnimaContadores();
}
}
@ -1129,6 +1202,71 @@
aplicaDestaque();
atualiza();
// Cadeia de ancestrais de uma linha (do mais próximo ao mais distante)
// — usado por expandeAte() abaixo pra reabrir só o caminho necessário
// até uma conta/linha específica, sem mexer no resto da árvore.
function ancestrais(id) {
var idx = linhas.findIndex(function (tr) {
return tr.getAttribute("data-dcr-id") === id;
});
if (idx === -1) return [];
var nivelAtual = Number(linhas[idx].getAttribute("data-dcr-nivel"));
var resultado = [];
for (var j = idx - 1; j >= 0 && nivelAtual > 0; j--) {
var nivel = Number(linhas[j].getAttribute("data-dcr-nivel"));
if (nivel < nivelAtual) {
resultado.push(linhas[j].getAttribute("data-dcr-id"));
nivelAtual = nivel;
}
}
return resultado;
}
// Expande todo ancestral colapsado de `id` (sem mexer no destaque —
// não é um clique de "expandir grupo", é só tornar a linha visível
// pra poder rolar até ela) e devolve o `<tr>` correspondente, ou
// `undefined` se `id` não existir nesta árvore. Usada pelo clique
// numa observação da seção "Observações do Balancete/D.R.E./Análise
// Vertical" (ver pidDcrObsResumo() mais abaixo).
function expandeAte(id) {
var mudou = false;
ancestrais(id).forEach(function (ancestralId) {
if (colapsadas[ancestralId]) {
colapsadas[ancestralId] = false;
mudou = true;
var trAncestral = tbody.querySelector('[data-dcr-id="' + ancestralId + '"]');
var toggleBtn = trAncestral && trAncestral.querySelector("[data-dcr-toggle]");
if (toggleBtn) toggleBtn.classList.add("is-expanded");
}
});
if (mudou) atualiza();
return tbody.querySelector('[data-dcr-id="' + id + '"]');
}
// "Limpar formatação" (botão no cabeçalho da seção) — desfaz
// qualquer expandir/recolher/destaque que o usuário tenha feito
// (inclusive via clique numa observação) e volta pro estado inicial
// do servidor (`data-dcr-colapsado-padrao`), sem precisar recarregar
// a página. Não mexe nos painéis de observação inline abertos — isso
// é `pidDcrObs()`, chamado à parte (ver invocação de `pidDcrReset()`
// mais abaixo).
function resetar() {
linhas.forEach(function (tr) {
var id = tr.getAttribute("data-dcr-id");
var padrao = tr.getAttribute("data-dcr-colapsado-padrao") === "1";
colapsadas[id] = padrao;
var toggleBtn = tr.querySelector("[data-dcr-toggle]");
if (toggleBtn) toggleBtn.classList.toggle("is-expanded", !padrao);
tr.classList.remove("dcr-row-flash");
});
historico = [];
destaque = calculaDestaqueInicial();
aplicaDestaque();
atualiza();
}
return { expandeAte: expandeAte, resetar: resetar };
}
// ---- Balancete/D.R.E.: painel de observação inline ----
@ -1170,14 +1308,98 @@
});
sincroniza();
// Fecha todo painel de observação inline aberto — parte do "Limpar
// formatação" (ver pidDcrReset() mais abaixo), que também chama
// arvore.resetar() da mesma tabela.
function fecharTudo() {
abertas = {};
tbody.querySelectorAll("[data-dcr-obs-toggle]").forEach(function (btn) {
btn.classList.remove("is-aberto");
});
sincroniza();
}
return { fecharTudo: fecharTudo };
}
pidDcrArvore("dcr-balancete-body");
pidDcrObs("dcr-balancete-body");
pidDcrArvore("dcr-dre-body");
pidDcrObs("dcr-dre-body");
pidDcrArvore("dcr-av-body");
pidDcrObs("dcr-av-body");
// ---- Observações (seção acima da tabela): clique leva até a conta/
// linha referenciada ----
// `container` é a `<ul class="dcr-obs-lista">` da seção; só os `<li>`
// com `data-dcr-obs-ir` são clicáveis (a view só preenche `obs.ancora`
// quando a conta/linha ainda existe nesta apuração — "se houver" a
// conta, ver `_com_ancora()` em views.py). `arvore` é o retorno de
// `pidDcrArvore()` da mesma tabela (Balancete/D.R.E./Análise Vertical
// sempre ficam na mesma aba que sua própria seção de observações,
// nunca precisa trocar de aba pra chegar na linha).
function pidDcrObsResumo(container, arvore) {
if (!container || !arvore) return;
function irPara(id) {
var tr = arvore.expandeAte(id);
if (!tr) return;
tr.scrollIntoView({ behavior: "smooth", block: "center" });
tr.classList.remove("dcr-row-flash");
void tr.offsetWidth; // força reflow pra reiniciar a animação
tr.classList.add("dcr-row-flash");
}
container.addEventListener("click", function (event) {
var item = event.target.closest("[data-dcr-obs-ir]");
if (item) irPara(item.getAttribute("data-dcr-obs-ir"));
});
// Teclado (role="button"/tabindex já no HTML) — Enter/Espaço ativam
// igual a um clique, mesmo padrão de acessibilidade de um botão real.
container.addEventListener("keydown", function (event) {
if (event.key !== "Enter" && event.key !== " ") return;
var item = event.target.closest("[data-dcr-obs-ir]");
if (!item) return;
event.preventDefault();
irPara(item.getAttribute("data-dcr-obs-ir"));
});
}
var dcrArvoreBalancete = pidDcrArvore("dcr-balancete-body");
var dcrObsBalancete = pidDcrObs("dcr-balancete-body");
var dcrArvoreDre = pidDcrArvore("dcr-dre-body");
var dcrObsDre = pidDcrObs("dcr-dre-body");
var dcrArvoreAv = pidDcrArvore("dcr-av-body");
var dcrObsAv = pidDcrObs("dcr-av-body");
pidDcrObsResumo(document.getElementById("dcr-obs-lista-balancete"), dcrArvoreBalancete);
pidDcrObsResumo(document.getElementById("dcr-obs-lista-dre"), dcrArvoreDre);
pidDcrObsResumo(document.getElementById("dcr-obs-lista-av"), dcrArvoreAv);
// ---- Botão "Limpar formatação" (um por tabela) ----
// Volta a árvore (expandido/recolhido/destaque) e os painéis de
// observação inline daquela tabela pro estado com que a página
// carregou, sem precisar recarregar nada. `data-dcr-reset` no botão
// carrega o mesmo id do `tbody` — é a chave usada nos dois mapas logo
// abaixo pra achar a árvore/painel de observação certos.
var dcrArvores = { "dcr-balancete-body": dcrArvoreBalancete, "dcr-dre-body": dcrArvoreDre, "dcr-av-body": dcrArvoreAv };
var dcrObsControles = { "dcr-balancete-body": dcrObsBalancete, "dcr-dre-body": dcrObsDre, "dcr-av-body": dcrObsAv };
document.querySelectorAll("[data-dcr-reset]").forEach(function (btn) {
btn.addEventListener("click", function () {
var tbodyId = btn.getAttribute("data-dcr-reset");
var arvore = dcrArvores[tbodyId];
var obsControle = dcrObsControles[tbodyId];
if (arvore) arvore.resetar();
if (obsControle) obsControle.fecharTudo();
});
});
// ---- Botão "Voltar ao topo" ----
// Aparece só depois de rolar (`PID_DCR_SCROLL_TOP_LIMIAR`), pra não
// ficar poluindo a tela logo no início do relatório.
(function () {
var PID_DCR_SCROLL_TOP_LIMIAR = 320;
var btn = document.getElementById("dcr-scroll-top-btn");
if (!btn) return;
window.addEventListener("scroll", function () {
btn.classList.toggle("is-visible", window.scrollY > PID_DCR_SCROLL_TOP_LIMIAR);
});
btn.addEventListener("click", function () {
window.scrollTo({ top: 0, behavior: "smooth" });
});
})();
// ---- Cards: contagem animada + cor por sinal/liquidez ----
// O texto final exibido é sempre `data-final` (a string já formatada
@ -1246,73 +1468,15 @@
});
}
// ---- Gráfico de evolução do Resultado Líquido ----
function inicializaGrafico() {
var dados = JSON.parse(document.getElementById("dcr-evolucao-data").textContent);
var canvas = document.getElementById("dcr-grafico-evolucao");
if (!canvas || dados.length < 2 || typeof Chart === "undefined") return;
var ctx = canvas.getContext("2d");
var gradiente = ctx.createLinearGradient(0, 0, 0, canvas.height || 260);
gradiente.addColorStop(0, "rgba(61, 33, 120, 0.28)");
gradiente.addColorStop(1, "rgba(61, 33, 120, 0.02)");
Chart.defaults.font.family = "Inter, sans-serif";
new Chart(ctx, {
type: "line",
data: {
labels: dados.map(function (p) { return p.competencia; }),
datasets: [{
label: "Resultado Líquido (R$)",
data: dados.map(function (p) { return parseFloat(p.resultado_liquido); }),
borderColor: "#3d2178",
backgroundColor: gradiente,
pointBackgroundColor: "#b4872a",
pointBorderColor: "#fff",
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
borderWidth: 2.5,
tension: 0.35,
fill: true,
}],
},
options: {
responsive: true,
animation: { duration: 900, easing: "easeOutCubic" },
interaction: { intersect: false, mode: "index" },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "#3d2178",
titleFont: { weight: "700" },
padding: 10,
cornerRadius: 8,
callbacks: {
label: function (item) { return "R$ " + item.parsed.y.toLocaleString("pt-BR", { minimumFractionDigits: 2 }); },
},
},
},
scales: {
x: { grid: { display: false } },
y: {
grid: { color: "rgba(228, 224, 236, 0.7)" },
ticks: { callback: function (v) { return "R$ " + v.toLocaleString("pt-BR"); } },
},
},
},
});
}
// Impressão mostra as 3 abas seguidas (ver @media print). Dois casos:
// (1) "Indicadores" nunca foi clicada na tela — gráfico e cards ainda
// não existem, então monta os dois agora, com os cards já no valor
// final (sem animação, ver `pidDcrAnimaContadores(true)` acima); (2)
// já foi clicada e a contagem pode estar em andamento — só finaliza
// os valores já em animação. Um único listener, pra nunca competir
// com o outro caso.
// Impressão mostra as 4 abas seguidas (ver @media print). Dois casos:
// (1) "Resumo" nunca foi clicada na tela — cards ainda não existem,
// então monta agora, já no valor final (sem animação, ver
// `pidDcrAnimaContadores(true)` acima); (2) já foi clicada e a
// contagem pode estar em andamento — só finaliza os valores já em
// animação. Um único listener, pra nunca competir com o outro caso.
window.addEventListener("beforeprint", function () {
if (!graficoInicializado) {
graficoInicializado = true;
inicializaGrafico();
if (!cardsInicializados) {
cardsInicializados = true;
pidDcrAnimaContadores(true);
} else {
finalizadores.forEach(function (fn) { fn(); });

View File

@ -112,7 +112,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
@ -619,6 +618,17 @@
</div>
</div>
<div class="modal-overlay" id="dc-obs-historico-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Histórico de edições</h2>
<p class="modal-card__subtitle">Toda mudança de texto desta observação, da mais recente para a mais antiga.</p>
<div class="dc-obs-historico-lista" id="dc-obs-historico-lista"></div>
<div class="modal-actions">
<button type="button" class="btn-solid" id="dc-obs-historico-modal-fechar-btn">Fechar</button>
</div>
</div>
</div>
<div class="modal-overlay" id="dc-indicador-formula-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title" id="dc-indicador-formula-modal-titulo"></h2>

View File

@ -112,7 +112,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -112,7 +112,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -112,7 +112,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -112,7 +112,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -112,7 +112,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -111,7 +111,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -113,7 +113,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -112,7 +112,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>

View File

@ -111,7 +111,6 @@
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>