Atualização aplicação Relatório Contábil
This commit is contained in:
parent
66e83090b6
commit
03af149104
6
plano.md
6
plano.md
@ -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`).
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
344
portal_api/dashboard_contabil/resumo_pdf.py
Normal file
344
portal_api/dashboard_contabil/resumo_pdf.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
31
portal_api/migrations/0072_contabilobservacaoedicao.py
Normal file
31
portal_api/migrations/0072_contabilobservacaoedicao.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
2
prd.md
@ -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)
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(); });
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user