Melhorias na Conciliação Fornecedores e Relatório Contábil

This commit is contained in:
Gabriel 2026-09-30 15:40:46 -03:00
parent 624f69dafd
commit 8218d58000
26 changed files with 2722 additions and 120 deletions

2
.gitignore vendored
View File

@ -63,3 +63,5 @@ desktop.ini
# Regras gerais de UI (uso local) e documentos de revisão gerados a partir delas
AGENT.MD
docs/revisao-interface-*.docx
media/conciliacao_fornecedores/Teste_Exportação_-_Fornecedores_-_2017_-_Atualizado_2__qjsN8iS.xlsx
media/conciliacao_fornecedores/Teste_Exportação_-_Fornecedores_-_2017.xlsx

View File

@ -82,7 +82,7 @@ Portal/
├── .env
├── config/ # settings.py, urls.py, wsgi.py, asgi.py — pacote de configuração do projeto
├── portal_api/ # único app Django (models, serializers, views, admin, migrations, seed)
├── templates/ # as 15 páginas HTML (13 shells + index.html + o relatório do Relatório Contábil) (TEMPLATES[0]["DIRS"] em settings.py aponta pra cá)
├── templates/ # as páginas HTML (shells + index.html + os relatórios do Relatório Contábil e da Conciliação de Fornecedores) (TEMPLATES[0]["DIRS"] em settings.py aponta pra cá)
├── static/ # css/, js/, img/ — STATICFILES_DIRS em settings.py aponta pra cá
├── media/ # upload de usuário (hoje só ícones de LinkFerramenta) — MEDIA_ROOT em settings.py
├── CLAUDE.md
@ -97,7 +97,7 @@ Ver `docs/identidade-visual/identidade-visual.md` para o inventário de cada arq
### Backend serve o frontend (mesma origem)
`config/urls.py` registra `path("api/", include("portal_api.urls"))` e, para cada uma das **15 páginas HTML** do frontend (`index.html` mais os 14 shells — ver a tabela em "Páginas" abaixo), uma rota `TemplateView` que resolve o arquivo em `templates/`. O 15º template, `dashboard-contabil-relatorio.html`, não tem rota própria: é renderizado por uma view do Relatório Contábil, não navegável pela URL. Os estáticos (`static/css`, `static/js`, `static/img`) são servidos por `django.contrib.staticfiles` automaticamente em `DEBUG` (via `STATICFILES_DIRS`) — não há mais nenhum `re_path`/`static_serve` manual em `urls.py`. Cada template usa `{% load static %}` + `{% static 'css/tokens.css' %}` (nunca um caminho hardcoded tipo `assets/css/...`, que não existe mais). Essa escolha (Django servindo o próprio frontend) existe para evitar CORS/cookie cross-origin: autenticação é por **sessão/cookie do Django**, então frontend e API precisam estar na mesma origem.
`config/urls.py` registra `path("api/", include("portal_api.urls"))` e, para cada uma das **15 páginas HTML** do frontend (`index.html` mais os 14 shells — ver a tabela em "Páginas" abaixo), uma rota `TemplateView` que resolve o arquivo em `templates/`. Dois templates não têm rota própria: `dashboard-contabil-relatorio.html` e `conciliacao-fornecedores-relatorio.html` são renderizados por uma view da aplicação correspondente (`/api/contabil-apuracoes/{id}/dashboard/` e `/api/conciliacoes-fornecedores/{id}/relatorio/`), não navegáveis pela URL de página. Os estáticos (`static/css`, `static/js`, `static/img`) são servidos por `django.contrib.staticfiles` automaticamente em `DEBUG` (via `STATICFILES_DIRS`) — não há mais nenhum `re_path`/`static_serve` manual em `urls.py`. Cada template usa `{% load static %}` + `{% static 'css/tokens.css' %}` (nunca um caminho hardcoded tipo `assets/css/...`, que não existe mais). Essa escolha (Django servindo o próprio frontend) existe para evitar CORS/cookie cross-origin: autenticação é por **sessão/cookie do Django**, então frontend e API precisam estar na mesma origem.
Em produção, rodar `python manage.py collectstatic` (junta tudo em `STATIC_ROOT = BASE_DIR / "staticfiles"`) e servir esse diretório via whitenoise/nginx — `django.contrib.staticfiles` só serve automaticamente quando `DEBUG=True`. Uploads de usuário (ícones de `LinkFerramenta`) são um mecanismo separado: `MEDIA_URL`/`MEDIA_ROOT` em `settings.py`, servidos por `config/urls.py` via `static()` só quando `DEBUG=True` (em produção, servir `media/` também por whitenoise/nginx, igual ao `STATIC_ROOT`).
@ -113,7 +113,7 @@ Um único app Django, `portal_api/`. **Os models, serializers e views de todas a
| `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view. Nenhuma subclasse nova é necessária para uma aplicação adotar o padrão, só instanciar com outra `app_key`. |
| `views.py` | Idem: todas as views. As transversais são `login_view`/`logout_view`/`csrf_view`, `me_view` (usuário + `permissoes_efetivas` já unidas no servidor + `lideranca`/`liderados` + `eh_perfil_inovacao`), `trocar_senha_view`, `usuarios_resumo_view`, `meus_liderados_view`, `departamentos_resumo_view`, `catalogo_view`, `ajuda_aplicacao_view`, e os `ModelViewSet` de perfis/departamentos/usuários/compromissos/favoritos/widgets/notificações dispensadas. |
| `admin.py` | Django admin básico para todos os models (uso interno, não é a UI do portal). |
| `templatetags/contabil_extras.py` | Filtros de template (`moeda`/`percentual`/`indice`/`competencia`/`mes_curto`/`moeda_av`/`percentual_av`/`numero_bruto`) — único uso de template tags customizadas no projeto, só pelo relatório do Relatório Contábil. |
| `templatetags/contabil_extras.py` | Filtros de template (`moeda`/`percentual`/`indice`/`competencia`/`mes_curto`/`moeda_av`/`percentual_av`/`numero_bruto`) — único uso de template tags customizadas no projeto, pelo relatório do Relatório Contábil e (`moeda`/`percentual`) pelo relatório da Conciliação de Fornecedores. |
| `management/commands/seed_portal.py` | Cria os 8 perfis padrão + `gabriel`/`bruno` + as 13 linhas de `FuncaoTelefonia` + o seed de `CategoriaEvento`; também realinha a sequence do Postgres por trás de `PerfilAcesso.codigo`. **Não rodar neste ambiente** — ver "Como rodar / testar localmente" acima. |
| `management/commands/seed_indicador_desempenho.py` | Popula o primeiro histórico do Indicador de Desempenho (7 `IndicadorCriterio` + 5 `IndicadorPercentualTipo`, idempotente). |
@ -125,7 +125,7 @@ Pacotes Python puros (sem ORM), um por ferramenta — cada um com seu próprio `
| `custo_contratacao/` | "Simulação de Custo de Contratação" (Geradoc) — `tabelas.py` (faixas de INSS/IRRF), `calculo.py`, `pdf.py` via `reportlab`. |
| `indicadores/` | "Indicador de Desempenho" (Geradoc) — `tipos.py`, `leiaute.py` (`openpyxl`), `pipeline.py`, `entregas.py`, `calculo.py`, `recibo.py` (PDF via `reportlab`), `departamentos.py`. |
| `nao_conformidades/` | "Não Conformidades" (Relatórios > Qualidade) — leiautes dos exports do Sigsistem, `diff.py` (reabertura automática), `classificacao.py`, `pipeline.py`. |
| `conciliacao_fornecedores/` | "Conciliação de Fornecedores" (Utilitários) — `parser.py` (razão do Questor em XLSX/CSV), `motor.py` (vínculos automáticos débito × crédito), `alertas.py` (situação/alertas recalculados a cada leitura), `exportacao.py` (XLSX). |
| `conciliacao_fornecedores/` | "Conciliação de Fornecedores" (Utilitários) — `parser.py` (razão do Questor em XLSX/CSV), `motor.py` (vínculos automáticos débito × crédito), `atualizacao.py` (razão novo × anterior na atualização), `alertas.py` (situação/alertas recalculados a cada leitura), `exportacao.py` (XLSX), `relatorio.py` (relatório de análise para o contador: decisões tomadas e situações de risco). |
| `dashboard_contabil/` | "Relatório Contábil" (Relatórios > Contabilidade) — `parser.py` (extração do PDF), `regras.py` (motor de auditoria), `formula.py` (avaliador de fórmula por `ast`), `indicadores.py`, `chaves.py` (chave natural de conta/linha, compartilhada por sincronização, observações e relatório), `exportacao.py` (XLSX), `resumo_pdf.py`. |
### API (sessão + CSRF, não token)
@ -345,7 +345,7 @@ Ver `portal_api/nao_conformidades/CLAUDE.md`.
## Conciliação de Fornecedores (Utilitários)
O contador informa o código da empresa (nome buscado no Questor) e anexa o razão contábil de fornecedores exportado do Questor; a ferramenta vincula pagamentos e títulos de cada fornecedor por valor e data (inclusive combinações e parcelas) e aponta o que está pendente: pagamentos sem título (divergência), diferenças de juros/desconto e conta transitória com saldo; conta só com títulos em aberto é conciliada, e os títulos em aberto há mais de 3 meses são revisados numa aba à parte, com validação própria por fornecedor. Vínculo manual, validação por conta, histórico salvo e exportação XLSX. Restrita ao perfil Inovação.
O contador informa o código da empresa (nome buscado no Questor) e anexa o razão contábil de fornecedores exportado do Questor; a ferramenta vincula pagamentos e títulos de cada fornecedor por valor e data (inclusive combinações e parcelas) e aponta o que está pendente: pagamentos sem título (divergência), diferenças de juros/desconto e conta transitória com saldo; conta só com títulos em aberto é conciliada, e os títulos em aberto há mais de 3 meses são revisados numa aba à parte, com validação própria por fornecedor. Vínculo manual, validação por conta, uma conciliação por empresa atualizada a cada razão novo (o arquivo novo substitui o anterior, mantendo o trabalho dos lançamentos que continuam nele), exportação XLSX e um relatório de análise para o contador (decisões tomadas + situações de risco, com risco geral). Restrita ao perfil Inovação.
Ver `portal_api/conciliacao_fornecedores/CLAUDE.md`.

View File

@ -12,6 +12,7 @@ Duas identidades visuais coexistem **de propósito** hoje: o logo cursivo "D De
- `logo.png` — original, texto **preto**. Serve como fonte pra gerar as outras variantes e é usada diretamente no recibo do Indicador de Desempenho (`indicadores/recibo.py`, `LOGO_PATH`, redimensionada/recomprimida em memória pra impressão — ver `portal_api/indicadores/CLAUDE.md`).
- `logo-branco.png` — usada no cabeçalho do PDF de Simulação de Custo de Contratação (`custo_contratacao/pdf.py`, banner marrom escuro, ver `portal_api/custo_contratacao/CLAUDE.md`) — até uma rodada anterior também era usada no `sidebar__brand` dos 13 shells, migrada pra marca "P.I.D." (ver abaixo; a UI do Portal e os documentos gerados usam fontes de logo independentes agora). Mesmo D colorido de `logo.png`, mas com o texto recolorido pra branco; gerada programaticamente a partir de `logo.png` (script Python com Pillow: qualquer pixel opaco quase-neutro/escuro — `max(r,g,b) < 70` e `spread(r,g,b) < 12` — virou branco; o D nunca entra nesse filtro porque mesmo na sombra mais escura do degradê ele mantém um matiz quente nitidamente não-neutro). Se o logo oficial mudar, regerar `logo-branco.png` a partir do novo `logo.png` com o mesmo filtro, não editar à mão.
- `logo-branco-relatorio.png` — cópia de `logo-branco.png` só redimensionada (480 px de largura, 40 KB, Pillow LANCZOS), usada no cabeçalho do relatório da Conciliação de Fornecedores (`conciliacao-fornecedores-relatorio.html`): o original, com 6250 px, pesava mais que o PDF impresso inteiro. Se `logo-branco.png` mudar, regerar esta a partir dele.
- `logo-mono.png` — versão totalmente monocromática (D **e** texto em branco/cinza claro). Não usada em nenhum consumidor hoje; existe como variante alternativa (útil se algum dia precisar de um logo "chapado" sem o dourado do D).
**Marca nova "P.I.D."**:

View File

@ -7,6 +7,7 @@ from .models import (
AjudaAplicacao,
CompromissoAgenda,
ConciliacaoFornecedor,
ConciliacaoFornecedorAtualizacao,
ConciliacaoFornecedorConta,
ConciliacaoFornecedorLancamento,
ConciliacaoFornecedorVinculo,
@ -341,6 +342,11 @@ class ConciliacaoFornecedorVinculoHistoricoAdmin(admin.ModelAdmin):
list_filter = ("acao",)
@admin.register(ConciliacaoFornecedorAtualizacao)
class ConciliacaoFornecedorAtualizacaoAdmin(admin.ModelAdmin):
list_display = ("conciliacao", "nome_arquivo", "periodo_inicio", "periodo_fim", "usuario", "criado_em")
@admin.register(ConciliacaoFornecedorLancamento)
class ConciliacaoFornecedorLancamentoAdmin(admin.ModelAdmin):
list_display = ("conta", "data", "natureza", "valor", "historico", "vinculo")

View File

@ -232,3 +232,41 @@ Pedido do usuário (título de 12/05/2026 de R$ 6.086,60 com o pagamento de R$ 2
### Rodada 45 — Sem sugestão quando não há justificativa (2026-09-29)
Pedido do usuário (FLASH COMPANY: pagamentos com "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..."): quando não houver justificativa para a sugestão, ela não deve ser feita; as sugestões devem fazer sentido. A sugestão fraca pela ordem de antiguidade, do lado do pagamento (rodadas 24 e 28 deste arquivo; no lado do título já tinha saído na rodada 35), deixou de existir: sem par de mesmo valor, valor próximo, combinação exata, parcela ou quitação exata dos mais antigos, o pagamento fica só "Pagamento não vinculado", sem sugestão nem `relacionados`. A distribuição por antiguidade continua, só para calcular o excedente (divergência). Conferido na conciliação 21 (só leitura, contra a versão da rodada 44): nenhuma conta mudou de situação nem de excedente; sobram 79 sugestões (52 de parcela, 19 de parcelamento no título, 4 de valor próximo, 4 de combinação/quitação); a FLASH COMPANY fica sem nenhuma.
### Rodada 46 — Relatório de análise para o contador (2026-09-30)
Pedido do usuário: um relatório final da conciliação, para análise do contador (não vai ao cliente, o escritório tem canal próprio de comunicação com ele), que centralize as decisões tomadas e alerte das situações que representam risco. Decisões confirmadas antes de implementar: **só o relatório**, sem etapa formal de "concluir conciliação" nem bloqueio de edição (o relatório pode ser gerado a qualquer momento e avisa no topo se ainda há contas sem validação); possível duplicidade = mesmo fornecedor, mesmo valor, **até 7 dias** entre os pagamentos; faixas dos títulos antigos **3 a 6, 6 a 12 e mais de 12 meses**.
O que foi construído: `relatorio.py` (Python puro, `monta_relatorio()`), a action `GET /api/conciliacoes-fornecedores/{id}/relatorio/` e o template `conciliacao-fornecedores-relatorio.html` (HTML autocontido com o logo do escritório, mesmo padrão do relatório do Relatório Contábil; abre em nova aba pelo botão "Gerar relatório" no cabeçalho do detalhe). Nenhum model novo: tudo sai da análise fresca, das validações e do histórico de vinculações. Seções: parecer (risco geral + síntese), cards de números, 1. Situações de risco, 2. Decisões tomadas, 3. O que ainda falta decidir, 4. Anexo por fornecedor.
Escolhas da implementação, não confirmadas pelo usuário: (a) um item de risco numa conta **validada por uma pessoa com observação** conta como justificado (nos títulos antigos, vale a revisão própria da aba); o risco geral só considera os itens sem justificativa; (b) concentração e juros/descontos são informativos, não entram no risco geral; (c) possível duplicidade só aponta o grupo com **algum pagamento ainda sem vínculo** (se todos foram vinculados a títulos, a repetição está explicada, ex.: frete semanal); (d) "pagamentos ainda não vinculados" entrou como risco médio, além da tabela combinada; (e) concentração mostra os 5 maiores fornecedores em aberto.
Conferido na conciliação 22 (empresa 2017, só leitura): risco geral alto (conta transitória com R$ 20.707,58, que também é o excedente, validada sem observação), 56 fornecedores com títulos antigos sem revisão (208 títulos de 3 a 6 meses, 90 de 6 a 12), R$ 267,63 pagos a maior e R$ 0,52 a menor em 4 contas, 36 contas com ajuste manual, 33 saldos de pagamento parcial, nenhuma duplicidade. HTML renderizado por `render_to_string` sem erro; visual não conferido em navegador neste ambiente.
### Rodada 47 — Impressão do relatório em PDF (2026-09-30)
Pedido do usuário (print do PDF): blocos pulavam para a página seguinte deixando espaço em branco, e o arquivo ficava pesado e carregado. Causas: `break-inside: avoid-page` em cada bloco de risco (um bloco maior que o resto da página ia inteiro para a seguinte), o `logo-branco.png` original com 6250 × 4419 px embutido no PDF, as fontes da web embutidas e o anexo de 260 fornecedores sempre impresso. Correções só no template: blocos quebram entre páginas (não quebram a linha de tabela, o cabeçalho do bloco e os cards; cabeçalho da tabela repete em cada página); `@page` A4 com margens menores, fonte 10 px em Arial, espaçamentos reduzidos, sem sombras; logo em cópia reduzida `static/img/logo-branco-relatorio.png` (480 px, mesma arte); anexo fora da impressão por padrão (opção "Incluir anexo na impressão" no cabeçalho, só na tela); na seção 3, a lista de títulos antigos sem revisão (a mesma do item 1) vira uma remissão na impressão; cabeçalhos de coluna mais curtos na tabela de ajustes. Conferido com Chrome headless na conciliação 22: 6 páginas sem anexo, 1,1 MB para 617 KB, sem página em branco.
### Rodada 48 — Atualização do razão e uma conciliação por empresa (2026-09-30)
Pedido do usuário: não manter histórico mensal fixo. Ao anexar um razão novo, a conciliação é atualizada com o período dele e a análise é refeita, por um botão para o contador anexar o relatório atualizado (espaço vazio ao lado de "Excluir" na lista). Decisões do usuário: o arquivo novo **substitui** o razão, e o trabalho feito só é mantido se o período novo englobar os lançamentos envolvidos ("se na análise anterior anexei 01 a 08/2026 e fiz um vínculo manual em 08, ao anexar 08 a 09/2026 o vínculo de 08 deve se manter"); conta que mudou volta a "a validar"; a revisão dos títulos antigos só é refeita se entrar título novo na lista; **uma conciliação por empresa**.
O que foi construído: `atualizacao.py` (pareamento puro dos lançamentos antigos com os do arquivo novo por saldo anterior?/data/sequência/natureza/valor), `_conciliacao_aplica_atualizacao()`/`_conciliacao_atualiza_conta()` em views.py, action `POST .../{id}/atualizar/`, model `ConciliacaoFornecedorAtualizacao` e a ação `removido` no histórico de vinculações (migração `0087`), parâmetro `fixos` em `motor.concilia()` (segmentação pelo saldo de todos os itens, vínculo novo só entre os livres; sem `fixos` o resultado é o mesmo de antes), recusa de nova conciliação para empresa que já tem uma (a tela oferece atualizar a existente com o mesmo arquivo), modal "Atualizar razão" com o resumo das contagens ao fim, `ultima_atualizacao` na lista, no detalhe e no relatório, mensagem de `pidConfirm`/`pidAlert` com quebra de linha (`white-space: pre-line` em `components.css`).
Escolhas da implementação, não confirmadas: lançamentos que o contador tirou de um vínculo automático não voltam a ser vinculados pelo motor na atualização; conta que não vem no arquivo novo é excluída; arquivo sem nenhuma conta em comum é recusado.
Conferido na conciliação 22, em transação desfeita (só banco, sem arquivo): (A) o mesmo arquivo de novo mantém tudo (2.609 lançamentos, 686 vínculos, 37 validações manuais, situações iguais); (B) o arquivo simulado de 01/06 a 31/08 (saldo anterior em 01/06) mantém os 17 vínculos manuais cujos lançamentos estão todos no período e desfaz os 59 que tinham lançamento anterior (registrados no histórico), mantém 158 vínculos no total, cria 55 automáticos novos e retira 34 validações. Com o simulado só de 08/2026 todos os vínculos são desfeitos: no arquivo de teste, agosto tem 284 títulos e 29 pagamentos, e todo vínculo tinha um lado antes de agosto.
### Rodada 49 — Razão exportado sem as colunas Débito e Crédito (2026-09-30)
Erro do usuário ao atualizar (`POST .../22/atualizar/` 400): o arquivo novo ("Teste Exportação - Fornecedores - 2017 - Atualizado.xlsx") veio num leiaute de 11 colunas, sem Débito e Crédito, e o parser exigia as 13 colunas na posição fixa ("O arquivo não parece ser a exportação do razão..."). Não era problema da atualização. `parser.py` passou a localizar as colunas pelo nome do cabeçalho; Débito, Crédito e Participante não são usados (o lado vem da coluna Valor). Conferido: o arquivo antigo (13 colunas) e o novo (11) dão o mesmo resultado (260 contas, 2.520 lançamentos, 01/01 a 31/08/2026, nenhum saldo divergente); a atualização simulada da conciliação 22 com o arquivo novo, em transação desfeita, mantém tudo (mesmo período).
### Rodada 50 — Período do razão: início pelo saldo anterior, fim informado ("Razão até") (2026-09-30)
Pergunta do usuário: o razão foi exportado até 30/09, mas sem lançamentos depois de 31/08, e a atualização mostrava o período até 31/08. A exportação do Questor não traz o período: ele era deduzido do primeiro e do último lançamento. Decisão do usuário (sugestão aceita): o início sai do arquivo, pela data do "Saldo anterior" + 1 dia (`parser.py`; antes, o primeiro lançamento); o fim é informado pelo contador num campo obrigatório "Razão até" nos formulários de Nova Conciliação e de Atualizar razão, já preenchido com o último dia do mês anterior (na atualização, nunca antes do fim atual). `periodo_fim` novo nos dois serializers (não pode ser futuro) e `_conciliacao_aplica_periodo_fim()` em views.py (não antes do último lançamento nem do início; na atualização, não antes do fim atual). `RazaoExtraido.ultimo_lancamento` guarda a data do último lançamento. O resumo da atualização diz "nenhum lançamento novo no período" quando nada entrou nem saiu.
Conferido na conciliação 22 com o arquivo atualizado, em transação desfeita: "Razão até" 30/08 é recusado (há lançamento em 31/08); 30/09 grava o período 01/01 a 30/09/2026 e mantém tudo (2.609 lançamentos, 686 vínculos, nenhum lançamento novo). As 260 contas do arquivo têm saldo anterior em 31/12/2025. Conciliações já existentes mantêm o período gravado até a próxima atualização.
### Rodada 51 — Barra de progresso ao criar e ao atualizar (2026-09-30)
Pedido do usuário: mostrar o andamento enquanto a conciliação é criada ou o razão é atualizado, como no Relatório Contábil. Mesmo mecanismo de lá (`pidDcIniciarProgressoSimulado`): o processamento é uma requisição síncrona, sem percentual real, então a barra sobe rápido e desacelera perto de 90%, e só chega a 100% quando a resposta volta; em erro, some e a mensagem aparece. `pidConcIniciarProgresso()` em `conciliacao-fornecedores.js`, `.conc-progress*` no CSS, uma barra no formulário de Nova Conciliação e outra no modal "Atualizar razão". "Cancelar" fica desabilitado enquanto processa.

View File

@ -10,9 +10,9 @@ O contador exporta do Questor o razão contábil das contas de fornecedores (que
- **Ferramenta do Portal, em Utilitários**, não um portal à parte (a ideia inicial de "outro site" foi descartada pelo usuário).
- **v1: só fornecedores.** O leiaute e o motor valem para qualquer razão de conta de passivo com o mesmo formato, mas as regras (crédito = título, débito = pagamento) assumem conta de fornecedor.
- **Entrada: XLSX ou CSV** da exportação "Questor_Exportacao" (mesmas 13 colunas). O XLSX é o preferido: datas e débito/crédito já vêm tipados. O CSV sai em Latin-1 com tudo em texto; `parser.py` aceita os dois.
- **Entrada: XLSX ou CSV** da exportação "Questor_Exportacao". Dois leiautes conhecidos: 13 colunas (com Débito e Crédito) e 11 colunas (sem elas, rodada 49 de `CHANGELOG.md` nesta pasta); o parser localiza as colunas **pelo nome do cabeçalho** (`COLUNAS_OBRIGATORIAS`/`COLUNAS_OPCIONAIS` em `parser.py`), e o lado do lançamento vem sempre da coluna Valor ("8.214,92D"). O XLSX é o preferido: datas e débito/crédito já vêm tipados. O CSV sai em Latin-1 com tudo em texto; `parser.py` aceita os dois.
- **Empresa informada pelo usuário**: o arquivo não traz código nem nome. O nome é resolvido no Questor ao digitar o código (`resolve_nome_empresa()`, mesma consulta/cache `EmpresaQuestor` do Cadastro de Regras do Plano de Saúde). Empresa não encontrada não bloqueia: a tela pede confirmação e grava o nome vazio.
- **Histórico salvo**, sem `unique_together`: a mesma empresa pode ser conciliada várias vezes.
- **Uma conciliação por empresa** (decisão do usuário, rodada 48 de `CHANGELOG.md` nesta pasta; antes era histórico com várias por empresa): o mês seguinte é uma **atualização do razão** na mesma conciliação, não outra. A criação recusa empresa que já tem conciliação (a tela oferece atualizar a existente com o mesmo arquivo). Sem `unique_together` no banco: as duplicadas antigas, se houver, continuam.
- **Tolerância de diferença: valor fixo e percentual, vale o maior**: R$ 10,00 / 2% (`TOLERANCIA_VALOR_PADRAO`/`TOLERANCIA_PERCENTUAL_PADRAO` em `motor.py`). **Internos ao Portal, não editáveis pela tela** (decisão do usuário): o formulário não tem esses campos e o servidor ignora qualquer valor enviado. Os valores usados ficam gravados em cada conciliação (`tolerancia_valor`/`tolerancia_percentual`), para uma mudança futura da constante não reescrever o histórico, mas **não aparecem na tela** (pedido do usuário): a tolerância só se manifesta como o alerta `diferenca_vinculo` no fornecedor em que foi usada. O usuário pediu "os dois"; o critério "o maior" foi escolha da implementação, porque o percentual cobre juros proporcionais e o fixo cobre centavos em títulos pequenos.
- **Título em aberto há mais de 3 meses** (`MESES_VENCIDO` em `alertas.py`; era 2 no pedido original, o usuário mudou para 3). "A partir da saída": contado da data do lançamento do título até a **data-base**, que é sempre o dia do processamento (não editável, mesma decisão das tolerâncias). Interpretação da implementação; confirmar se o usuário disser o contrário.
- **Conta transitória** ("TRANSFERÊNCIA PARA CUSTOS E DESPESAS" no arquivo real): não deveria ter saldo, qualquer saldo é alerta alto. Detectada por trecho do nome (`TRECHOS_CONTA_TRANSITORIA` em `alertas.py`).
@ -21,9 +21,9 @@ O contador exporta do Questor o razão contábil das contas de fornecedores (que
## Leitura do razão (`parser.py`)
Cada conta começa numa linha "Conta: <reduzido> - <classificação> <fornecedor>" (coluna Histórico), seguida de "Saldo anterior..." (só a coluna Saldo) e dos lançamentos. Não há linha de total nem cabeçalho de empresa/período. Valores com lado "8.214,92D"/"16.429,84C". **Convenção interna: credor positivo** (valor devido ao fornecedor). `saldo_final_calculado` (saldo anterior + créditos − débitos) é comparado com o último saldo impresso (`saldo_final_arquivo`); diferença vira alerta alto (exportação incompleta). No arquivo de referência (`Projetos\Conciliação\Teste Exportação - Fornecedores - 2017.xlsx`, empresa 2017, 260 contas, 2.520 lançamentos, jan-ago/2026) as 260 contas batem.
Cada conta começa numa linha "Conta: <reduzido> - <classificação> <fornecedor>" (coluna Histórico), seguida de "Saldo anterior..." (só a coluna Saldo) e dos lançamentos. Não há linha de total nem cabeçalho de empresa/período: o **início** do período é a data do "Saldo anterior" + 1 dia (não o primeiro lançamento, que pode vir depois), e o **fim** é informado pelo contador no campo "Razão até" da criação e da atualização (o arquivo só traz até o último lançamento, e um mês sem movimento encurtaria o período; rodada 50 do CHANGELOG). `_conciliacao_aplica_periodo_fim()` (views.py) recusa data antes do último lançamento, antes do início, futura (serializer) ou, na atualização, antes do fim atual. Valores com lado "8.214,92D"/"16.429,84C". **Convenção interna: credor positivo** (valor devido ao fornecedor). `saldo_final_calculado` (saldo anterior + créditos − débitos) é comparado com o último saldo impresso (`saldo_final_arquivo`); diferença vira alerta alto (exportação incompleta). No arquivo de referência (`Projetos\Conciliação\Teste Exportação - Fornecedores - 2017.xlsx`, empresa 2017, 260 contas, 2.520 lançamentos, jan-ago/2026) as 260 contas batem.
O cabeçalho é conferido pelas duas primeiras letras de cada coluna (sem acento), para tolerar "Seqüência" com acento corrompido.
Cada coluna é achada pelo início do nome no cabeçalho, só letras sem acento (ex. "SE" para "Seqüência", que pode vir com o acento corrompido; "DESC" para Descrição, para não confundir com Débito). Obrigatórias: Data, Seqüência, Histórico, Contrapartida, Descrição, Valor, Saldo; opcionais: Filial, Usuário, Origem; Débito, Crédito e Participante são ignoradas.
## Motor (`motor.py`)
@ -65,9 +65,30 @@ Status: `divergente` (algum alerta alto) > `atencao` (médio/baixo) > `conciliad
**Revisão dos títulos antigos é outra validação, por fornecedor** (pedido do usuário): `vencidos_validados`/`vencidos_validados_por`/`vencidos_validados_em`/`vencidos_observacao` na conta (migração `0086`), gravados por `validar-vencidos/`. Independente de `validada`: validar uma não mexe na outra, e a validação automática nunca toca nesses campos. Não é zerada se os títulos antigos mudarem (ex.: um deles for vinculado depois); se o fornecedor deixar de ter título antigo, só sai da aba.
## Atualização do razão (`atualizacao.py` + `_conciliacao_aplica_atualizacao()` em views.py)
Botão "Atualizar" na lista e "Atualizar razão" no detalhe: o contador anexa o razão novo e **ele substitui** o da conciliação (decisão do usuário, rodada 48 de `CHANGELOG.md` nesta pasta). O que ficou antes do início do arquivo novo passa a ser só o saldo anterior de cada conta (sem composição). O trabalho feito só é mantido onde os lançamentos envolvidos continuam no arquivo novo (exemplo do usuário: conciliação de 01 a 08/2026 com vínculo manual em 08; atualizando com 08 a 09/2026, o vínculo de 08 fica).
- **Pareamento** (`atualizacao.compara()`): por conta (conta reduzida + classificação, senão só a reduzida), lançamento antigo = novo quando bate (saldo anterior?, data, sequência, natureza, valor); o mantido **conserva o id** (e com ele o vínculo e as referências do histórico) e recebe os demais campos do arquivo novo.
- **Vínculo** com algum lançamento que saiu do razão é desfeito inteiro (o resto volta a pendente); o manual/parcial fica no histórico com `acao="removido"` ("Desfeito na atualização do razão", borda dourada tracejada no modal), que **não** conta como ajuste do contador em `_conciliacao_contas_ajustadas()`.
- **Motor** roda de novo sobre a conta inteira com `concilia(itens, fixos)`: a segmentação usa o saldo corrente de todos os itens, mas só os livres entram em vínculo novo; `fixos` = já vinculados + os que o contador tirou de um vínculo automático (histórico `desfez` automático), para o motor não refazer o vínculo que ele desfez. Compensação de segmento fechado só se os livres somarem zero.
- **Validação**: conta com lançamento novo ou removido e validada por uma pessoa volta a "a validar" (observação fica); a automática é reaplicada depois (decisão do usuário). **Revisão dos títulos antigos**: só é retirada se entrou título novo na lista de vencidos (comparando antes, na data-base anterior, e depois, na nova); decisão do usuário.
- **Contas**: conta nova no arquivo é criada; conta que não veio no arquivo novo é **excluída** (substituição). Arquivo sem nenhuma conta em comum é recusado (provável empresa errada).
- **Cabeçalho**: período = o do arquivo novo (início do saldo anterior, fim = "Razão até"), data-base = dia da atualização, arquivo substituído (o antigo é apagado do disco depois do commit). Cada atualização gera um `ConciliacaoFornecedorAtualizacao` (quem, quando, arquivo, período novo e anterior, `resumo` com as contagens), exposto como `ultima_atualizacao` no payload e mostrado na lista, no detalhe e no relatório.
## Relatório de análise (`relatorio.py`)
Documento **para o contador**, não para o cliente (pedido do usuário: o escritório tem canal próprio com o cliente). Centraliza as decisões tomadas e aponta as situações de risco. `monta_relatorio()` recebe as contas já analisadas (convertidas por `_conciliacao_monta_relatorio()` em views.py) e o histórico de vinculações; **nada é gravado** e não há etapa de "concluir" (decisão do usuário, rodada 46 de `CHANGELOG.md` nesta pasta): pode ser gerado a qualquer momento, e o topo avisa quantas contas ainda estão sem validação. Renderizado em `templates/conciliacao-fornecedores-relatorio.html` (HTML autocontido, logo do escritório, paleta fixa, imprimível; os `<details>` abrem todos ao imprimir), aberto em nova aba pelo botão "Gerar relatório". **Impressão** (rodada 47 de `CHANGELOG.md` nesta pasta): A4 compacto, fonte Arial (as fontes da web embutidas pesavam no PDF), logo em cópia reduzida (`logo-branco-relatorio.png`); os blocos podem quebrar entre páginas (só a linha de tabela, o cabeçalho de cada bloco e os cards não quebram; nunca pôr `break-inside: avoid` no bloco inteiro, que empurrava blocos grandes para a página seguinte e deixava a anterior em branco); o anexo só é impresso se marcado "Incluir anexo na impressão" no cabeçalho; a lista de títulos antigos sem revisão da seção 3 vira uma remissão ao item 1 na impressão (é a mesma lista).
- **Parecer**: risco geral (alto/moderado/baixo) = maior severidade entre os itens de risco **sem justificativa**; síntese em frases; cards (saldo, em aberto, há mais de 3 meses com fornecedores revisados, pagamentos sem título, validação).
- **1. Situações de risco**: alto = saldo recalculado ≠ arquivo, conta transitória com saldo, saldo devedor, pagamentos excedentes, conta **Divergente validada por uma pessoa sem observação**; médio = possível duplicidade (`JANELA_DIAS_DUPLICIDADE` = 7 dias entre pagamentos de mesmo valor na mesma conta, só grupo com algum pagamento sem vínculo), pagamentos ainda não vinculados, títulos há mais de 3 meses (com a tabela das faixas `FAIXAS_VENCIDOS_MESES` = 3 a 6, 6 a 12, mais de 12); baixo, informativo = juros (pago a maior) e descontos (pago a menor) dos vínculos `manual`/`tolerancia`. Mais a concentração do saldo em aberto (5 maiores, informativa).
- **Justificativa**: item numa conta validada por uma pessoa com observação aparece como justificado, com a observação; nos títulos antigos, vale `vencidos_validados` (+ `vencidos_observacao`). A validação automática não justifica nada. Escolha da implementação, não confirmada.
- **2. Decisões tomadas**: contas validadas por uma pessoa com situação ≠ conciliada (com a justificativa ou "Sem justificativa"), ajustes manuais por conta (contagem de vinculou/acrescentou/desfez automático/desfez manual, responsáveis, última ação), saldos de pagamento parcial, títulos antigos revisados.
- **3. O que ainda falta decidir**: contas sem validação e fornecedores com títulos antigos sem revisão. **4. Anexo**: todas as contas.
## Models (`portal_api/models.py`)
`ConciliacaoFornecedor` (cabeçalho: empresa, data-base, período, arquivo, tolerâncias, `resumo` JSON) → `ConciliacaoFornecedorConta` (saldos, `validada`/`validada_por`/`validada_em`, `observacao`, e a validação à parte dos títulos antigos `vencidos_validados`/`_por`/`_em`/`vencidos_observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente). `ConciliacaoFornecedorVinculoHistorico` (por conta): cada ação manual sobre vínculo (`acao` vinculou/acrescentou/desfez, este último inclusive para vínculo automático), com `usuario`, `criado_em`, `tipo_rotulo`/`vinculo_automatico` e **uma cópia dos lançamentos envolvidos** em `lancamentos` (JSON com seq., data, natureza, valor e `acrescentado`), porque o vínculo desfeito é apagado. Gravado por `_conciliacao_registra_historico()` dentro da mesma transação da ação. Vínculos criados pelo motor na importação **não** entram (não são ação de ninguém); ações anteriores à rodada 23 não têm registro.
`ConciliacaoFornecedor` (cabeçalho: empresa, data-base, período, arquivo, tolerâncias, `resumo` JSON) → `ConciliacaoFornecedorConta` (saldos, `validada`/`validada_por`/`validada_em`, `observacao`, e a validação à parte dos títulos antigos `vencidos_validados`/`_por`/`_em`/`vencidos_observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente). `ConciliacaoFornecedorAtualizacao` (por conciliação, migração `0087`): registro de cada atualização do razão. `ConciliacaoFornecedorVinculoHistorico` (por conta): cada ação manual sobre vínculo (`acao` vinculou/acrescentou/desfez, este último inclusive para vínculo automático, mais `removido` = desfeito pela atualização do razão), com `usuario`, `criado_em`, `tipo_rotulo`/`vinculo_automatico` e **uma cópia dos lançamentos envolvidos** em `lancamentos` (JSON com seq., data, natureza, valor e `acrescentado`), porque o vínculo desfeito é apagado. Gravado por `_conciliacao_registra_historico()` dentro da mesma transação da ação. Vínculos criados pelo motor na importação **não** entram (não são ação de ninguém); ações anteriores à rodada 23 não têm registro.
## API
@ -75,10 +96,12 @@ Todas com `PermissaoApp("utilitarios", "conciliacao-fornecedores")`.
| Endpoint | Método | Uso |
|---|---|---|
| `/api/conciliacoes-fornecedores/` | GET/POST | histórico (`?codigo_empresa=` filtra) / criação multipart (só `codigo_empresa` + `arquivo`; data-base e tolerâncias são definidas no servidor) |
| `/api/conciliacoes-fornecedores/` | GET/POST | histórico (`?codigo_empresa=` filtra) / criação multipart (só `codigo_empresa` + `arquivo` + `periodo_fim` ("Razão até"); data-base e tolerâncias são definidas no servidor) |
| `/api/conciliacoes-fornecedores/{id}/` | GET/DELETE | detalhe completo (contas + lançamentos + vínculos + análise, ~1 MB para 2.500 lançamentos; reaplica a validação automática e regrava o `resumo` se mudou) / exclusão (apaga o arquivo depois do commit) |
| `/api/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=` | GET | nome no Questor (`null` se não achou ou Questor fora) |
| `/api/conciliacoes-fornecedores/{id}/exportar-xlsx/` | GET | planilha com abas Resumo, Pendências e Alertas |
| `/api/conciliacoes-fornecedores/{id}/atualizar/` | POST | multipart `arquivo` + `periodo_fim` ("Razão até", não antes do fim atual): razão novo que substitui o da conciliação (ver "Atualização do razão"); devolve `{conciliacao, atualizacao}` (contagens) |
| `/api/conciliacoes-fornecedores/{id}/relatorio/` | GET | relatório de análise para o contador (HTML, ver "Relatório de análise"); GET porque a tela abre a URL direto numa nova aba |
| `/api/conciliacoes-fornecedores-contas/{id}/vincular/` | POST | `{lancamentos: [ids]}`: vínculo manual; exige itens sem vínculo, da própria conta, com pelo menos um débito e um crédito. Créditos maiores que débitos **além da tolerância** da conciliação viram `tipo="parcial"` (pagamento parcial: a `diferenca` continua em aberto, com a data do crédito mais antigo do vínculo para o aging, e entra nos totais em aberto/vencido. Nas sugestões, **só por base concreta** (mesmo valor, valor próximo, parcela: "Possível parcela do saldo a pagar do título de 28/05/2026 (R$ 6.490,65 em aberto), em 3x de R$ 2.163,55"), **nunca pela ordem de antiguidade** (rodadas 38 e 41 de `CHANGELOG.md` nesta pasta; `_eh_saldo_parcial()`/`_ref_titulo()` em `alertas.py`)); dentro da tolerância, ou pago a maior, `tipo="manual"` (fecha, com alerta de diferença) |
| `/api/conciliacoes-fornecedores-contas/{id}/validar/` | POST | `{validada, observacao}` |
| `/api/conciliacoes-fornecedores-contas/{id}/validar-vencidos/` | POST | `{validada, observacao?}`: validação e observação dos títulos em aberto há mais de 3 meses do fornecedor (aba própria), independentes de `validar/` |
@ -112,5 +135,6 @@ A tabela de fornecedores, **ordenada por padrão pela situação, divergentes pr
- O vínculo é uma **sugestão**: sem número de NF no pagamento, títulos de mesmo valor podem ser trocados entre si. Os saldos continuam certos.
- Combinações fora dos limites (mais de 6 itens, mais de 120 dias, fora dos 12 mais próximos) não são encontradas; sobram para a distribuição ou para o vínculo manual.
- O saldo anterior não tem composição: entra como um bloco único.
- Não há reprocessamento: para trocar o arquivo, excluir e criar de novo (ajustes manuais se perdem).
- Exportação só em XLSX (PDF com o logo do escritório ainda não pedido).
- Na atualização do razão, o período anterior ao arquivo novo vira um saldo anterior sem composição: títulos em aberto de antes do início do arquivo novo deixam de aparecer um a um. Para mantê-los, anexar um período que comece antes deles.
- Lançamento alterado no Questor (valor, data ou sequência) conta como removido + novo na atualização, e o vínculo dele é desfeito.
- Exportação em XLSX e o relatório de análise em HTML (imprimível); não há PDF gerado no servidor.

View File

@ -8,12 +8,14 @@ Ferramenta para o contador conciliar as contas de fornecedores de uma empresa. E
- mostra, por fornecedor, conta, saldo inicial, saldo residual e a situação (conciliada, pendente de pagamento, atenção, divergente, validada);
- separa numa tela própria os títulos em aberto há mais de 3 meses, com validação por fornecedor (não bloqueiam a conciliação); alerta pagamentos sem título (divergência), diferenças pequenas (possível juros/desconto) e conta transitória com saldo;
- deixa o contador desfazer e criar vínculos, registrar observação e validar a conta;
- guarda o histórico e exporta o resultado em XLSX.
- mantém uma conciliação por empresa: no mês seguinte, o contador anexa o razão atualizado ("Atualizar"), que substitui o anterior e refaz a análise, mantendo os vínculos e decisões dos lançamentos que continuam no arquivo novo;
- exporta o resultado em XLSX;
- gera um relatório de análise para o contador, que reúne as decisões tomadas (validações, ajustes de vínculo, revisão dos títulos antigos) e aponta as situações de risco (conta transitória com saldo, saldo devedor, pagamento sem título, possível duplicidade, títulos antigos por faixa), com um risco geral.
Acesso restrito ao perfil "Inovação", liberado para outros perfis pela tela de Perfis de Acesso.
## Onde mexer
- `portal_api/conciliacao_fornecedores/`: `parser.py` (leitura do razão), `motor.py` (vínculos automáticos), `alertas.py` (situação e alertas), `exportacao.py` (XLSX).
- `portal_api/conciliacao_fornecedores/`: `parser.py` (leitura do razão), `motor.py` (vínculos automáticos), `alertas.py` (situação e alertas), `exportacao.py` (XLSX), `atualizacao.py` (comparação do razão novo com o anterior), `relatorio.py` (relatório de análise, template `conciliacao-fornecedores-relatorio.html`).
- `ConciliacaoFornecedor`, `ConciliacaoFornecedorConta`, `ConciliacaoFornecedorVinculo`, `ConciliacaoFornecedorLancamento` (`portal_api/models.py`).
- `conciliacao-fornecedores.html` / `static/js/conciliacao-fornecedores.js` / `static/css/conciliacao-fornecedores.css`.

View File

@ -0,0 +1,70 @@
"""Atualização do razão de uma conciliação (pedido do usuário, rodada 48 do
`CHANGELOG.md` nesta pasta): o arquivo novo **substitui** o razão da
conciliação. O que ficou antes do início do arquivo novo passa a ser só o
saldo anterior dele, e o trabalho feito (vínculos, observações) é mantido
apenas onde os lançamentos envolvidos continuam no arquivo novo.
Aqui fica só a comparação entre os lançamentos que a conciliação já tinha e
os do arquivo novo, por conta. Python puro, sem ORM; a gravação é da view.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
ChaveLancamento = tuple[bool, date, str, str, Decimal]
@dataclass
class LancamentoComparavel:
"""Um lançamento já gravado (`ref` = id) ou do arquivo novo (`ref` =
posição na lista nova)."""
ref: int
eh_saldo_anterior: bool
data: date
sequencia: str
natureza: str
valor: Decimal
@property
def chave(self) -> ChaveLancamento:
# A sequência do Questor identifica o lançamento; o saldo anterior não
# tem sequência e só é o mesmo se a data e o valor forem os mesmos.
sequencia = "" if self.eh_saldo_anterior else self.sequencia.strip()
return (self.eh_saldo_anterior, self.data, sequencia, self.natureza, self.valor)
@dataclass
class Comparacao:
mantidos: dict[int, int] # posição no arquivo novo -> id do lançamento já gravado
novos: list[int] # posições no arquivo novo sem correspondente
removidos: list[int] # ids já gravados que não estão no arquivo novo
def compara(antigos: list[LancamentoComparavel], novos: list[LancamentoComparavel]) -> Comparacao:
"""Pareia pela chave (saldo anterior?, data, sequência, natureza, valor).
Chaves repetidas (raro, ex. dois lançamentos sem sequência) pareiam na
ordem em que aparecem."""
por_chave: dict[ChaveLancamento, list[int]] = {}
for antigo in antigos:
por_chave.setdefault(antigo.chave, []).append(antigo.ref)
mantidos: dict[int, int] = {}
sem_par: list[int] = []
for novo in novos:
fila = por_chave.get(novo.chave)
if fila:
mantidos[novo.ref] = fila.pop(0)
else:
sem_par.append(novo.ref)
usados = set(mantidos.values())
removidos = [a.ref for a in antigos if a.ref not in usados]
return Comparacao(mantidos=mantidos, novos=sem_par, removidos=removidos)
def vinculos_afetados(lancamentos_por_vinculo: dict[int, set[int]], removidos: set[int]) -> set[int]:
"""Vínculos com algum lançamento que saiu do razão: são desfeitos inteiros
(o que sobrar deles volta a pendente)."""
return {vinculo for vinculo, ids in lancamentos_por_vinculo.items() if ids & removidos}

View File

@ -300,21 +300,28 @@ def _sem_vinculo(itens: list[ItemConciliacao], vinculos: list[VinculoSugerido])
return [i for i in itens if i.indice not in usados]
def concilia(itens: list[ItemConciliacao]) -> list[VinculoSugerido]:
def concilia(itens: list[ItemConciliacao], fixos: frozenset[int] = frozenset()) -> list[VinculoSugerido]:
"""Roda as etapas descritas no topo do módulo para uma conta. `itens`
precisa estar na ordem do arquivo (é ela que define o saldo corrente)."""
precisa estar na ordem do arquivo (é ela que define o saldo corrente).
`fixos` (índices): itens que não podem entrar em vínculo novo, na
atualização do razão (os que já têm vínculo mantido, ou que o contador
tirou de um vínculo automático). Continuam contando no saldo corrente, para
a segmentação ser a mesma do arquivo inteiro; a compensação do segmento
fechado só acontece se os itens livres do segmento somarem zero sozinhos."""
vinculos: list[VinculoSugerido] = []
segmentos = _segmentos(itens)
for posicao, segmento in enumerate(segmentos):
if not segmento:
livres = [i for i in segmento if i.indice not in fixos]
if not livres:
continue
aberto = posicao == len(segmentos) - 1
locais = _pareia_exato(segmento)
locais = _pareia_exato(livres)
# A combinação exata (etapa 3) e a quitação dos títulos mais antigos
# (etapa 4) não vinculam mais sozinhas (pedido do usuário,
# 2026-09-29): viram sugestão em `alertas.py`.
restantes = _sem_vinculo(segmento, locais)
if not aberto and restantes:
restantes = _sem_vinculo(livres, locais)
if not aberto and restantes and sum(i.assinado for i in restantes) == 0:
locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes))
vinculos += locais
return vinculos

View File

@ -1,15 +1,20 @@
"""Leitura do razão contábil de fornecedores exportado do Questor
("Questor_Exportacao"), em XLSX ou CSV — as duas exportações trazem as
mesmas 13 colunas, na mesma ordem:
("Questor_Exportacao"), em XLSX ou CSV. Há dois leiautes conhecidos, e as
colunas são localizadas **pelo nome do cabeçalho**, não pela posição:
Data, Seqüência, Histórico, Contrapartida, Descrição, Valor, Débito,
Crédito, Participante, Saldo, Filial, Usuário, Origem
13 colunas: Data, Seqüência, Histórico, Contrapartida, Descrição, Valor,
Débito, Crédito, Participante, Saldo, Filial, Usuário, Origem
11 colunas: o mesmo sem Débito e Crédito (rodada 49 do CHANGELOG)
Débito/Crédito não são usados: o lado vem da própria coluna Valor
("8.214,92D").
Cada conta começa numa linha só com "Conta: <reduzido> - <classificação>
<fornecedor>" na coluna Histórico, seguida de "Saldo anterior..." (data do
dia anterior ao período, só a coluna Saldo preenchida) e dos lançamentos.
Não há linha de total por conta nem cabeçalho com empresa/período — a
empresa é informada pelo usuário e o período sai das datas dos lançamentos.
empresa é informada pelo usuário, o início do período sai da data do "Saldo
anterior" (+1 dia) e o fim é informado pelo contador ("Razão até").
Python puro, sem ORM (mesmo espírito de `dashboard_contabil/parser.py`):
recebe o conteúdo do arquivo em bytes e devolve dataclasses.
@ -25,26 +30,24 @@ import io
import re
import unicodedata
from dataclasses import dataclass, field
from datetime import date, datetime
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from openpyxl import load_workbook
COLUNAS_ESPERADAS = [
"DATA",
"SEQUENCIA",
"HISTORICO",
"CONTRAPARTIDA",
"DESCRICAO",
"VALOR",
"DEBITO",
"CREDITO",
"PARTICIPANTE",
"SALDO",
"FILIAL",
"USUARIO",
"ORIGEM",
]
# Coluna -> início do nome no cabeçalho (só letras ASCII, sem acento). Curto
# de propósito: o "Seqüência" do Questor pode chegar com o acento corrompido
# ("SEQNCIA"). "DESC" e não "DE", para não confundir com "Débito".
COLUNAS_OBRIGATORIAS = {
"data": "DA",
"sequencia": "SE",
"historico": "HI",
"contrapartida": "CO",
"descricao": "DESC",
"valor": "VA",
"saldo": "SA",
}
COLUNAS_OPCIONAIS = {"filial": "FI", "usuario": "US", "origem": "OR"}
_RE_CONTA = re.compile(r"^Conta:\s*(\d+)\s*-\s*([\d.]+)\s+(.*)$")
_RE_VALOR_DC = re.compile(r"^(-?[\d.]+,\d{2})\s*([DC])$")
@ -101,8 +104,15 @@ class ContaExtraida:
class RazaoExtraido:
contas: list[ContaExtraida]
periodo_inicio: date | None
# Sai do arquivo como a data do último lançamento; a view troca pela data
# "Razão até" informada pelo contador, que pode ser posterior.
periodo_fim: date | None
@property
def ultimo_lancamento(self) -> date | None:
datas = [lanc.data for conta in self.contas for lanc in conta.lancamentos]
return max(datas) if datas else None
def _sem_acento(texto: str) -> str:
normalizado = unicodedata.normalize("NFKD", texto)
@ -179,21 +189,26 @@ def _linhas_csv(conteudo: bytes) -> list[list[object]]:
return [list(linha) for linha in csv.reader(io.StringIO(texto), delimiter=delimitador)]
def _confere_cabecalho(cabecalho: list[object]) -> None:
# Compara só as letras ASCII, sem acento: o "Seqüência" do Questor pode
# chegar com o acento corrompido dependendo de como o arquivo foi salvo.
def _mapeia_cabecalho(cabecalho: list[object]) -> dict[str, int]:
"""Posição de cada coluna usada, pelo nome no cabeçalho (primeira que
bater). Falta de coluna obrigatória: não é a exportação esperada."""
def chave(valor: object) -> str:
return re.sub(r"[^A-Z]", "", _sem_acento(_texto(valor)))
lidas = [chave(c) for c in cabecalho[: len(COLUNAS_ESPERADAS)]]
esperadas = [re.sub(r"[^A-Z]", "", c) for c in COLUNAS_ESPERADAS]
divergentes = [esperada for esperada, lida in zip(esperadas, lidas) if lida[:2] != esperada[:2]]
if len(lidas) < len(esperadas) or divergentes:
lidas = [chave(c) for c in cabecalho]
posicoes: dict[str, int] = {}
for coluna, prefixo in {**COLUNAS_OBRIGATORIAS, **COLUNAS_OPCIONAIS}.items():
posicao = next((i for i, lida in enumerate(lidas) if lida.startswith(prefixo)), None)
if posicao is not None:
posicoes[coluna] = posicao
faltando = [coluna for coluna in COLUNAS_OBRIGATORIAS if coluna not in posicoes]
if faltando:
raise ArquivoRazaoInvalidoError(
"O arquivo não parece ser a exportação do razão de fornecedores do Questor "
"(colunas esperadas: Data, Seqüência, Histórico, Contrapartida, Descrição, Valor, "
"Débito, Crédito, Participante, Saldo, Filial, Usuário, Origem)."
"(colunas esperadas: Data, Seqüência, Histórico, Contrapartida, Descrição, Valor e Saldo)."
)
return posicoes
def extrai_razao(conteudo: bytes, nome_arquivo: str) -> RazaoExtraido:
@ -208,16 +223,22 @@ def extrai_razao(conteudo: bytes, nome_arquivo: str) -> RazaoExtraido:
if not linhas:
raise ArquivoRazaoInvalidoError("O arquivo está vazio.")
_confere_cabecalho(linhas[0])
posicoes = _mapeia_cabecalho(linhas[0])
largura = len(linhas[0])
contas: list[ContaExtraida] = []
atual: ContaExtraida | None = None
ordem_lancamento = 0
for numero_linha, bruta in enumerate(linhas[1:], start=2):
celulas = (bruta + [None] * len(COLUNAS_ESPERADAS))[: len(COLUNAS_ESPERADAS)]
historico = _texto(celulas[2])
if not any(_texto(c) for c in celulas):
if not any(_texto(c) for c in bruta):
continue
linha = list(bruta) + [None] * max(0, largura - len(bruta))
def celula(coluna: str) -> object:
posicao = posicoes.get(coluna)
return None if posicao is None else linha[posicao]
historico = _texto(celula("historico"))
casamento_conta = _RE_CONTA.match(historico)
if casamento_conta:
@ -234,12 +255,12 @@ def extrai_razao(conteudo: bytes, nome_arquivo: str) -> RazaoExtraido:
raise ArquivoRazaoInvalidoError(f"Linha {numero_linha}: lançamento antes da primeira linha \"Conta:\".")
if _sem_acento(historico).startswith("SALDO ANTERIOR"):
atual.saldo_inicial = _saldo_assinado(celulas[9]) or Decimal("0")
atual.data_saldo_inicial = _data(celulas[0])
atual.saldo_inicial = _saldo_assinado(celula("saldo")) or Decimal("0")
atual.data_saldo_inicial = _data(celula("data"))
continue
data_lanc = _data(celulas[0])
valor = _valor_dc(celulas[5])
data_lanc = _data(celula("data"))
valor = _valor_dc(celula("valor"))
if data_lanc is None or valor is None:
raise ArquivoRazaoInvalidoError(f"Linha {numero_linha}: lançamento sem data ou sem valor.")
numero, natureza = valor
@ -247,16 +268,16 @@ def extrai_razao(conteudo: bytes, nome_arquivo: str) -> RazaoExtraido:
LancamentoExtraido(
ordem=ordem_lancamento,
data=data_lanc,
sequencia=_texto(celulas[1]),
sequencia=_texto(celula("sequencia")),
historico=historico,
contrapartida_codigo=_texto(celulas[3]),
contrapartida_descricao=_texto(celulas[4]),
contrapartida_codigo=_texto(celula("contrapartida")),
contrapartida_descricao=_texto(celula("descricao")),
natureza=natureza,
valor=numero,
saldo_apos=_saldo_assinado(celulas[9]),
filial=_texto(celulas[10]),
usuario=_texto(celulas[11]),
origem=_texto(celulas[12]),
saldo_apos=_saldo_assinado(celula("saldo")),
filial=_texto(celula("filial")),
usuario=_texto(celula("usuario")),
origem=_texto(celula("origem")),
)
)
ordem_lancamento += 1
@ -265,8 +286,14 @@ def extrai_razao(conteudo: bytes, nome_arquivo: str) -> RazaoExtraido:
raise ArquivoRazaoInvalidoError("Nenhuma conta encontrada no arquivo (linhas \"Conta: ...\").")
datas = [lanc.data for conta in contas for lanc in conta.lancamentos]
# O início sai da linha "Saldo anterior" (datada do dia anterior ao
# período), não do primeiro lançamento: o período pode começar sem
# movimento. O fim não existe no arquivo: é informado pelo contador (ver
# `ultimo_lancamento` e a rodada 50 do CHANGELOG da app).
datas_saldo = [conta.data_saldo_inicial for conta in contas if conta.data_saldo_inicial]
inicio = min(datas_saldo) + timedelta(days=1) if datas_saldo else (min(datas) if datas else None)
return RazaoExtraido(
contas=contas,
periodo_inicio=min(datas) if datas else None,
periodo_inicio=inicio,
periodo_fim=max(datas) if datas else None,
)

View File

@ -0,0 +1,702 @@
"""Relatório de análise da conciliação de fornecedores, para o contador (não
vai ao cliente: o escritório tem canal próprio de comunicação com ele).
Centraliza as decisões tomadas na conciliação e aponta as situações que
representam risco.
Nada aqui é gravado: o relatório é montado a cada geração a partir da
análise fresca de cada conta (`alertas.analisa_conta()`), das validações e
do histórico de vinculações. Pode ser gerado a qualquer momento, inclusive
com a conciliação em andamento (o cabeçalho avisa quantas contas faltam).
**Justificativa**: um item de risco numa conta validada por uma pessoa com
observação registrada é mostrado como justificado, com a observação (a
decisão já foi tomada e está explicada); nos títulos antigos, vale a revisão
própria da aba "Em aberto há mais de 3 meses". O risco geral só considera os
itens ainda sem justificativa, e ignora os blocos só informativos
(concentração e juros/descontos).
Python puro, sem ORM.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from . import alertas
from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA
# Dois pagamentos de mesmo valor ao mesmo fornecedor com até 7 dias entre um
# e outro são possível duplicidade (sugestão aceita pelo usuário).
JANELA_DIAS_DUPLICIDADE = 7
# Faixas dos títulos em aberto há mais de `alertas.MESES_VENCIDO` meses.
FAIXAS_VENCIDOS_MESES = (6, 12)
# Concentração: quantos fornecedores listar.
MAX_CONCENTRACAO = 5
SEVERIDADES = (alertas.SEVERIDADE_ALTA, alertas.SEVERIDADE_MEDIA, alertas.SEVERIDADE_BAIXA)
ROTULO_SEVERIDADE = {alertas.SEVERIDADE_ALTA: "Alto", alertas.SEVERIDADE_MEDIA: "Médio", alertas.SEVERIDADE_BAIXA: "Baixo"}
ROTULO_STATUS = {
alertas.STATUS_CONCILIADA: "Conciliada",
alertas.STATUS_ATENCAO: "Atenção",
alertas.STATUS_DIVERGENTE: "Divergente",
}
RISCO_GERAL_ALTO = "alto"
RISCO_GERAL_MODERADO = "moderado"
RISCO_GERAL_BAIXO = "baixo"
ROTULO_RISCO_GERAL = {RISCO_GERAL_ALTO: "Alto", RISCO_GERAL_MODERADO: "Moderado", RISCO_GERAL_BAIXO: "Baixo"}
# --- Entrada ----------------------------------------------------------------
@dataclass
class LancamentoRelatorio:
id: int
data: date
natureza: str # "C" ou "D"
valor: Decimal
vinculo_id: int | None
eh_saldo_anterior: bool
sequencia: str
@dataclass
class VinculoRelatorio:
id: int
tipo: str
diferenca: Decimal
data_titulo: date | None
criado_por: str | None
@dataclass
class HistoricoRelatorio:
acao: str # "vinculou", "acrescentou" ou "desfez"
vinculo_automatico: bool
usuario: str | None
criado_em: datetime
@dataclass
class ContaRelatorio:
conta_numero: str
fornecedor: str
saldo_inicial: Decimal
saldo_final: Decimal
saldo_final_arquivo: Decimal
analise: alertas.AnaliseConta
lancamentos: list[LancamentoRelatorio]
vinculos: list[VinculoRelatorio]
historico: list[HistoricoRelatorio]
validada: bool
validada_por: str | None # None com `validada` = validação automática
validada_em: datetime | None
observacao: str
vencidos_validados: bool
vencidos_validados_por: str | None
vencidos_validados_em: datetime | None
vencidos_observacao: str
# --- Saída ------------------------------------------------------------------
@dataclass
class RiscoItem:
conta_numero: str
fornecedor: str
valor: Decimal | None
detalhe: str
justificativa: str = "" # observação de quem validou; vazio = sem justificativa
justificado: bool = False
@dataclass
class Risco:
chave: str
severidade: str
titulo: str
porque: str
itens: list[RiscoItem]
# informativo: aparece no relatório, mas não entra no risco geral
informativo: bool = False
@property
def severidade_rotulo(self) -> str:
return ROTULO_SEVERIDADE[self.severidade]
@property
def valor_total(self) -> Decimal:
return sum((abs(i.valor) for i in self.itens if i.valor is not None), Decimal("0"))
@property
def qtd_sem_justificativa(self) -> int:
return sum(1 for i in self.itens if not i.justificado)
@dataclass
class FaixaVencidos:
rotulo: str
qtd_titulos: int = 0
valor: Decimal = Decimal("0")
fornecedores: set[str] = field(default_factory=set)
fornecedores_sem_revisao: set[str] = field(default_factory=set)
@property
def qtd_fornecedores(self) -> int:
return len(self.fornecedores)
@property
def qtd_sem_revisao(self) -> int:
return len(self.fornecedores_sem_revisao)
@dataclass
class ConcentracaoItem:
conta_numero: str
fornecedor: str
valor: Decimal
participacao: Decimal # fração (0.25 = 25%)
@dataclass
class ValidacaoComPendencia:
conta_numero: str
fornecedor: str
status_rotulo: str
status: str
alertas: list[str]
validada_por: str
validada_em: datetime | None
observacao: str
@dataclass
class AjusteConta:
conta_numero: str
fornecedor: str
vinculou: int
acrescentou: int
desfez_automatico: int
desfez_manual: int
usuarios: list[str]
ultima_acao: datetime
@dataclass
class SaldoParcial:
conta_numero: str
fornecedor: str
valor: Decimal
data_titulo: date | None
criado_por: str | None
@dataclass
class VencidosRevisados:
conta_numero: str
fornecedor: str
qtd_titulos: int
valor: Decimal
mais_antigo: date | None
revisado_por: str | None
revisado_em: datetime | None
observacao: str
@dataclass
class ContaPendente:
conta_numero: str
fornecedor: str
status: str
status_rotulo: str
motivo: str
valor: Decimal
@dataclass
class LinhaAnexo:
conta_numero: str
fornecedor: str
saldo_inicial: Decimal
saldo_final: Decimal
total_em_aberto: Decimal
total_vencido: Decimal
status: str
status_rotulo: str
validacao: str # nome de quem validou, "Automática" ou ""
@dataclass
class RelatorioConciliacao:
risco_geral: str
sintese: list[str]
total_contas: int
contas_validadas: int
por_status: dict[str, int]
saldo_total: Decimal
total_em_aberto: Decimal
total_vencido: Decimal
total_nao_vinculado: Decimal
total_excedente: Decimal
responsaveis: list[str]
riscos: list[Risco]
faixas_vencidos: list[FaixaVencidos]
concentracao: list[ConcentracaoItem]
total_juros: Decimal
total_descontos: Decimal
validacoes_com_pendencia: list[ValidacaoComPendencia]
ajustes: list[AjusteConta]
saldos_parciais: list[SaldoParcial]
vencidos_revisados: list[VencidosRevisados]
contas_nao_validadas: list[ContaPendente]
vencidos_nao_revisados: list[VencidosRevisados]
anexo: list[LinhaAnexo]
@property
def risco_geral_rotulo(self) -> str:
return ROTULO_RISCO_GERAL[self.risco_geral]
@property
def concluida(self) -> bool:
return not self.contas_nao_validadas
@property
def qtd_fornecedores_vencidos(self) -> int:
return len(self.vencidos_revisados) + len(self.vencidos_nao_revisados)
@property
def percentual_validado(self) -> int:
return round(100 * self.contas_validadas / self.total_contas) if self.total_contas else 100
# --- Montagem ---------------------------------------------------------------
def _moeda(valor: Decimal) -> str:
return alertas._moeda(valor)
def _data(valor: date) -> str:
return f"{valor:%d/%m/%Y}"
def _justificativa_conta(conta: ContaRelatorio) -> str:
"""Observação da validação feita por uma pessoa; a validação automática
(conta conciliada sem ajuste) não justifica risco nenhum."""
if conta.validada and conta.validada_por and conta.observacao.strip():
return conta.observacao.strip()
return ""
def _item(conta: ContaRelatorio, valor: Decimal | None, detalhe: str, justificativa: str | None = None) -> RiscoItem:
texto = _justificativa_conta(conta) if justificativa is None else justificativa
return RiscoItem(conta.conta_numero, conta.fornecedor, valor, detalhe, texto, bool(texto))
def _data_item_vencido(conta: ContaRelatorio, item_id: int) -> date | None:
"""Data do título (lançamento) ou, id negativo, do título original do
pagamento parcial."""
if item_id < 0:
return next((v.data_titulo for v in conta.vinculos if v.id == -item_id), None)
return next((l.data for l in conta.lancamentos if l.id == item_id), None)
def _titulos_vencidos(conta: ContaRelatorio) -> list[tuple[date, Decimal]]:
titulos = []
for item_id, situacao in conta.analise.situacoes.items():
if situacao.categoria != alertas.CATEGORIA_VENCIDO:
continue
data = _data_item_vencido(conta, item_id)
if data is not None:
titulos.append((data, situacao.valor_em_aberto))
return sorted(titulos)
def _duplicidades(conta: ContaRelatorio) -> list[list[LancamentoRelatorio]]:
"""Grupos de pagamentos de mesmo valor com até `JANELA_DIAS_DUPLICIDADE`
dias entre um e o seguinte. Só entra o grupo com algum pagamento ainda
sem vínculo: se todos já foram vinculados, cada um a um título, a
repetição está explicada pelos próprios títulos (ex.: frete semanal de
mesmo valor)."""
por_valor: dict[Decimal, list[LancamentoRelatorio]] = {}
for lanc in conta.lancamentos:
if lanc.natureza == "D" and not lanc.eh_saldo_anterior and lanc.valor > 0:
por_valor.setdefault(lanc.valor, []).append(lanc)
grupos: list[list[LancamentoRelatorio]] = []
for pagamentos in por_valor.values():
if len(pagamentos) < 2:
continue
pagamentos.sort(key=lambda l: (l.data, l.id))
atual = [pagamentos[0]]
for lanc in pagamentos[1:]:
if (lanc.data - atual[-1].data).days <= JANELA_DIAS_DUPLICIDADE:
atual.append(lanc)
continue
grupos.append(atual)
atual = [lanc]
grupos.append(atual)
return [g for g in grupos if len(g) >= 2 and any(l.vinculo_id is None for l in g)]
def _faixa(data: date, data_base: date) -> int:
"""Índice da faixa: 0 = 3 a 6 meses, 1 = 6 a 12, 2 = mais de 12."""
for indice, meses in enumerate(FAIXAS_VENCIDOS_MESES):
if data >= alertas._soma_meses(data_base, -meses):
return indice
return len(FAIXAS_VENCIDOS_MESES)
def monta_relatorio(contas: list[ContaRelatorio], data_base: date, processado_por: str | None) -> RelatorioConciliacao:
riscos: dict[str, Risco] = {
chave: Risco(chave, severidade, titulo, porque, [], informativo)
for chave, severidade, titulo, porque, informativo in (
(
"saldo_divergente_arquivo",
alertas.SEVERIDADE_ALTA,
"Saldo do razão diferente do saldo impresso",
"A exportação pode ter vindo incompleta; a conciliação dessas contas pode estar errada.",
False,
),
(
"conta_transitoria",
alertas.SEVERIDADE_ALTA,
"Conta transitória com saldo",
"A conta deveria estar zerada; o saldo indica lançamento não transferido para custo ou despesa.",
False,
),
(
"saldo_devedor",
alertas.SEVERIDADE_ALTA,
"Fornecedor com saldo devedor",
"Adiantamento não baixado, pagamento em duplicidade ou título não lançado.",
False,
),
(
"debito_excedente",
alertas.SEVERIDADE_ALTA,
"Pagamentos além de todos os títulos em aberto",
"Pagamento sem documento correspondente: despesa sem comprovação ou título não escriturado.",
False,
),
(
"validada_sem_justificativa",
alertas.SEVERIDADE_ALTA,
"Conta validada com divergência e sem observação",
"A decisão foi tomada, mas não há justificativa registrada para quem revisar depois.",
False,
),
(
"duplicidade",
alertas.SEVERIDADE_MEDIA,
"Possível pagamento em duplicidade",
f"Pagamentos de mesmo valor ao mesmo fornecedor com até {JANELA_DIAS_DUPLICIDADE} dias entre eles, "
"com pelo menos um ainda sem título vinculado.",
False,
),
(
"pagamento_sem_titulo",
alertas.SEVERIDADE_MEDIA,
"Pagamentos ainda não vinculados a título",
"Enquanto não forem vinculados, não se sabe qual título cada pagamento quitou.",
False,
),
(
"vencidos",
alertas.SEVERIDADE_MEDIA,
f"Títulos em aberto há mais de {alertas.MESES_VENCIDO} meses",
"Passivo que talvez já tenha sido pago por fora, ou baixa que não foi registrada.",
False,
),
(
"juros_descontos",
alertas.SEVERIDADE_BAIXA,
"Juros e descontos implícitos nos vínculos",
"Diferenças aceitas nos vínculos: podem pedir o lançamento de juros pagos ou de descontos obtidos.",
True,
),
)
}
faixas = [
FaixaVencidos(f"{alertas.MESES_VENCIDO} a {FAIXAS_VENCIDOS_MESES[0]} meses"),
FaixaVencidos(f"{FAIXAS_VENCIDOS_MESES[0]} a {FAIXAS_VENCIDOS_MESES[1]} meses"),
FaixaVencidos(f"Mais de {FAIXAS_VENCIDOS_MESES[1]} meses"),
]
por_status = {chave: 0 for chave in ROTULO_STATUS}
saldo_total = total_aberto = total_vencido = total_nao_vinculado = total_excedente = Decimal("0")
total_juros = total_descontos = Decimal("0")
responsaveis: set[str] = {processado_por} if processado_por else set()
validacoes: list[ValidacaoComPendencia] = []
ajustes: list[AjusteConta] = []
saldos_parciais: list[SaldoParcial] = []
vencidos_revisados: list[VencidosRevisados] = []
vencidos_nao_revisados: list[VencidosRevisados] = []
nao_validadas: list[ContaPendente] = []
anexo: list[LinhaAnexo] = []
validadas = 0
for conta in contas:
analise = conta.analise
tipos_alerta = {a.tipo for a in analise.alertas}
por_status[analise.status] += 1
validadas += conta.validada
saldo_total += conta.saldo_final
total_aberto += analise.total_em_aberto
total_vencido += analise.total_vencido
total_nao_vinculado += analise.total_nao_vinculado
total_excedente += analise.total_excedente
for nome in (conta.validada_por, conta.vencidos_validados_por, *(h.usuario for h in conta.historico)):
if nome:
responsaveis.add(nome)
# riscos que vêm direto dos alertas da conta
if "saldo_divergente_arquivo" in tipos_alerta:
riscos["saldo_divergente_arquivo"].itens.append(
_item(
conta,
conta.saldo_final - conta.saldo_final_arquivo,
f"Recalculado {_moeda(conta.saldo_final)}, último saldo do arquivo {_moeda(conta.saldo_final_arquivo)}.",
)
)
if "conta_transitoria" in tipos_alerta:
lado = "devedor" if conta.saldo_final < 0 else "credor"
riscos["conta_transitoria"].itens.append(_item(conta, conta.saldo_final, f"Saldo {lado} de {_moeda(conta.saldo_final)}."))
if "saldo_devedor" in tipos_alerta:
riscos["saldo_devedor"].itens.append(_item(conta, conta.saldo_final, f"Saldo devedor de {_moeda(conta.saldo_final)}."))
if analise.total_excedente > 0:
qtd = sum(1 for s in analise.situacoes.values() if s.categoria == alertas.CATEGORIA_EXCEDENTE)
riscos["debito_excedente"].itens.append(
_item(conta, analise.total_excedente, f"{qtd} pagamento(s) sem título em aberto correspondente.")
)
if analise.total_nao_vinculado > 0:
qtd = sum(1 for s in analise.situacoes.values() if s.categoria == alertas.CATEGORIA_NAO_VINCULADO)
riscos["pagamento_sem_titulo"].itens.append(
_item(conta, analise.total_nao_vinculado, f"{qtd} pagamento(s) a vincular.")
)
if (
analise.status == alertas.STATUS_DIVERGENTE
and conta.validada
and conta.validada_por
and not conta.observacao.strip()
):
riscos["validada_sem_justificativa"].itens.append(
_item(
conta,
None,
f"Validada por {conta.validada_por}"
+ (f" em {conta.validada_em:%d/%m/%Y}" if conta.validada_em else "")
+ ". "
+ " ".join(a.mensagem for a in analise.alertas if a.severidade == alertas.SEVERIDADE_ALTA),
justificativa="",
)
)
for grupo in _duplicidades(conta):
sem_vinculo = sum(1 for l in grupo if l.vinculo_id is None)
datas = ", ".join(_data(l.data) + (f" (seq. {l.sequencia})" if l.sequencia else "") for l in grupo)
riscos["duplicidade"].itens.append(
_item(
conta,
grupo[0].valor,
f"{len(grupo)} pagamentos de {_moeda(grupo[0].valor)}: {datas}; {sem_vinculo} sem título vinculado.",
)
)
# juros/descontos: diferença aceita num vínculo que fecha (manual ou
# por tolerância). O parcial não entra: a diferença dele é saldo a pagar.
juros = descontos = Decimal("0")
for vinculo in conta.vinculos:
if vinculo.tipo not in (TIPO_MANUAL, TIPO_TOLERANCIA) or vinculo.diferenca == 0:
continue
if vinculo.diferenca < 0:
juros += -vinculo.diferenca
else:
descontos += vinculo.diferenca
if juros or descontos:
total_juros += juros
total_descontos += descontos
partes = []
if juros:
partes.append(f"pago a maior {_moeda(juros)} (possível juros/multa)")
if descontos:
partes.append(f"pago a menor {_moeda(descontos)} (possível desconto)")
texto = "; ".join(partes)
riscos["juros_descontos"].itens.append(_item(conta, juros + descontos, texto[0].upper() + texto[1:] + "."))
# títulos antigos, por faixa
titulos = _titulos_vencidos(conta)
if titulos:
for data, valor in titulos:
faixa = faixas[_faixa(data, data_base)]
faixa.qtd_titulos += 1
faixa.valor += valor
faixa.fornecedores.add(conta.conta_numero)
if not conta.vencidos_validados:
faixa.fornecedores_sem_revisao.add(conta.conta_numero)
mais_antigo = titulos[0][0]
revisao = VencidosRevisados(
conta.conta_numero,
conta.fornecedor,
len(titulos),
analise.total_vencido,
mais_antigo,
conta.vencidos_validados_por,
conta.vencidos_validados_em,
conta.vencidos_observacao.strip(),
)
(vencidos_revisados if conta.vencidos_validados else vencidos_nao_revisados).append(revisao)
justificativa = ""
if conta.vencidos_validados:
justificativa = conta.vencidos_observacao.strip() or f"Revisado por {conta.vencidos_validados_por or 'usuário'}, sem observação."
riscos["vencidos"].itens.append(
_item(
conta,
analise.total_vencido,
f"{len(titulos)} título(s); o mais antigo de {_data(mais_antigo)} ({(data_base - mais_antigo).days} dias).",
justificativa=justificativa,
)
)
# decisões
if conta.validada and conta.validada_por and analise.status != alertas.STATUS_CONCILIADA:
validacoes.append(
ValidacaoComPendencia(
conta.conta_numero,
conta.fornecedor,
ROTULO_STATUS[analise.status],
analise.status,
[a.mensagem for a in analise.alertas],
conta.validada_por,
conta.validada_em,
conta.observacao.strip(),
)
)
if conta.historico:
ajustes.append(
AjusteConta(
conta.conta_numero,
conta.fornecedor,
vinculou=sum(1 for h in conta.historico if h.acao == "vinculou"),
acrescentou=sum(1 for h in conta.historico if h.acao == "acrescentou"),
desfez_automatico=sum(1 for h in conta.historico if h.acao == "desfez" and h.vinculo_automatico),
desfez_manual=sum(1 for h in conta.historico if h.acao == "desfez" and not h.vinculo_automatico),
usuarios=sorted({h.usuario for h in conta.historico if h.usuario}),
ultima_acao=max(h.criado_em for h in conta.historico),
)
)
for vinculo in conta.vinculos:
if vinculo.tipo == TIPO_PARCIAL and vinculo.diferenca > 0:
saldos_parciais.append(
SaldoParcial(conta.conta_numero, conta.fornecedor, vinculo.diferenca, vinculo.data_titulo, vinculo.criado_por)
)
if not conta.validada:
motivo = analise.alertas[0].mensagem if analise.alertas else "Sem alertas; falta a validação."
nao_validadas.append(
ContaPendente(
conta.conta_numero,
conta.fornecedor,
analise.status,
ROTULO_STATUS[analise.status],
motivo,
analise.total_excedente + analise.total_nao_vinculado,
)
)
anexo.append(
LinhaAnexo(
conta.conta_numero,
conta.fornecedor,
conta.saldo_inicial,
conta.saldo_final,
analise.total_em_aberto,
analise.total_vencido,
analise.status,
ROTULO_STATUS[analise.status],
(conta.validada_por or "Automática") if conta.validada else "",
)
)
# concentração do saldo em aberto
concentracao: list[ConcentracaoItem] = []
if total_aberto > 0:
maiores = sorted(
((c for c in contas if c.analise.total_em_aberto > 0)),
key=lambda c: c.analise.total_em_aberto,
reverse=True,
)[:MAX_CONCENTRACAO]
concentracao = [
ConcentracaoItem(c.conta_numero, c.fornecedor, c.analise.total_em_aberto, c.analise.total_em_aberto / total_aberto)
for c in maiores
]
# ordem dentro de cada risco: sem justificativa primeiro, depois maior valor
for risco in riscos.values():
risco.itens.sort(key=lambda i: (i.justificado, -(abs(i.valor) if i.valor is not None else Decimal("0"))))
lista_riscos = sorted(
(r for r in riscos.values() if r.itens),
key=lambda r: (SEVERIDADES.index(r.severidade), r.informativo),
)
pendentes_por_severidade = {
sev: sum(r.qtd_sem_justificativa for r in lista_riscos if r.severidade == sev and not r.informativo)
for sev in SEVERIDADES
}
if pendentes_por_severidade[alertas.SEVERIDADE_ALTA]:
risco_geral = RISCO_GERAL_ALTO
elif pendentes_por_severidade[alertas.SEVERIDADE_MEDIA]:
risco_geral = RISCO_GERAL_MODERADO
else:
risco_geral = RISCO_GERAL_BAIXO
sintese: list[str] = []
for sev, rotulo in ((alertas.SEVERIDADE_ALTA, "alto"), (alertas.SEVERIDADE_MEDIA, "médio")):
qtd = pendentes_por_severidade[sev]
if qtd:
sintese.append(f"{qtd} {'ponto' if qtd == 1 else 'pontos'} de risco {rotulo} sem justificativa")
justificados = sum(len(r.itens) - r.qtd_sem_justificativa for r in lista_riscos if not r.informativo)
if justificados:
sintese.append(f"{justificados} {'ponto justificado' if justificados == 1 else 'pontos justificados'} pelo contador")
if validacoes:
sintese.append(f"{len(validacoes)} {'conta validada' if len(validacoes) == 1 else 'contas validadas'} com pendência")
if nao_validadas:
sintese.append(f"{len(nao_validadas)} {'conta' if len(nao_validadas) == 1 else 'contas'} ainda sem validação")
if not sintese:
sintese.append("Nenhum ponto de risco pendente")
# divergentes primeiro, depois atenção e conciliadas
ordem_status = (alertas.STATUS_DIVERGENTE, alertas.STATUS_ATENCAO, alertas.STATUS_CONCILIADA)
nao_validadas.sort(key=lambda c: (ordem_status.index(c.status), -c.valor))
validacoes.sort(key=lambda v: (v.status != alertas.STATUS_DIVERGENTE, bool(v.observacao)))
ajustes.sort(key=lambda a: a.ultima_acao, reverse=True)
saldos_parciais.sort(key=lambda s: s.valor, reverse=True)
vencidos_revisados.sort(key=lambda v: (v.mais_antigo or data_base, -v.valor))
vencidos_nao_revisados.sort(key=lambda v: (v.mais_antigo or data_base, -v.valor))
return RelatorioConciliacao(
risco_geral=risco_geral,
sintese=sintese,
total_contas=len(contas),
contas_validadas=validadas,
por_status=por_status,
saldo_total=saldo_total,
total_em_aberto=total_aberto,
total_vencido=total_vencido,
total_nao_vinculado=total_nao_vinculado,
total_excedente=total_excedente,
responsaveis=sorted(responsaveis),
riscos=lista_riscos,
faixas_vencidos=faixas if any(f.qtd_titulos for f in faixas) else [],
concentracao=concentracao,
total_juros=total_juros,
total_descontos=total_descontos,
validacoes_com_pendencia=validacoes,
ajustes=ajustes,
saldos_parciais=saldos_parciais,
vencidos_revisados=vencidos_revisados,
contas_nao_validadas=nao_validadas,
vencidos_nao_revisados=vencidos_nao_revisados,
anexo=anexo,
)

View File

@ -496,3 +496,13 @@ Mexe em `.py`: exige reiniciar o `runserver`. A migração `0081` já foi aplica
- O primeiro teste deu 404 no validar. Não era bug: o `runserver` tinha carregado o `views.py` 5 segundos antes da gravação da action nova e o recarregamento automático se perdeu (4 processos `runserver` encadeados). Resolvido reiniciando o servidor.
- As variações saíram da aba Observações (lista, donut e card "Variações e Indicadores"), já que têm lista própria no fim da aba Análise Vertical (`dcAchadosDaAbaObservacoes()`). Com isso o botão Validar que tinha sido posto nos cards daquela aba também saiu.
- Linha da Análise Vertical com variação atípica ganhou um ícone antes do botão de validado (dourado pendente, teal validada). Hover mostra a variação e as observações da linha; clique leva ao card da variação com a thread aberta, para validar e comentar.
### Rodada 154 — Aba Observações sinaliza as variações da Análise Vertical
Pedido do usuário, com print: numa análise que só tem apontamentos de variação atípica, a aba Observações abria vazia ("Nenhuma observação para este filtro."), já que essas variações saíram dela na rodada 153 deste arquivo. Isso confundia o contador. Pedido: um card e uma linha informando quantos apontamentos existem na Análise Vertical, levando direto até eles.
- Card "Análise Vertical" no resumo (ao lado de "Divergências de Saldo"/"Contas Atípicas"), só quando há variação: total, "Variação atípica na DRE" e "Pendentes de validação". O clique não filtra a lista (as variações continuam fora dela): troca para a aba Análise Vertical e rola até a lista de variações no fim (`dcIrParaListaVariacoes()`).
- Linha clicável abaixo da lista (`#dc-av-aviso`, `renderAvisoVariacoes()`): "N apontamentos de variação atípica na Análise Vertical (M pendentes de validação)" ou "(todos validados)", mesmo destino.
- Validar uma variação na Análise Vertical agora redesenha também a aba Observações, para as contagens de pendentes acompanharem.
Só `.html`/`.css`/`.js`: não exige reiniciar o `runserver`. O JS não pôde ser executado neste ambiente: conferir na tela.

View File

@ -392,7 +392,7 @@ Pedido explícito do usuário: os apontamentos de `variacao_atipica_dre` aparece
- **Validar trata o apontamento** (`status="tratado"`, com quem/quando) pelo endpoint `validar/`, sem o modal "Revisar". Clicar em "Validado" volta a pendente (`dcValidarVariacaoBtnHtml()`/`dcAlternarValidacaoVariacao()`).
- **Ícone na própria linha da tabela** (pedido do usuário, `dcVariacaoIconeHtml()`/`PID_DC_VARIACAO_ICONE`), antes do botão de validado: dourado enquanto pendente, teal depois de validada. O hover (`.dc-hover-tooltip--largo`) mostra título, mensagem, quem validou e até 3 observações da linha; o clique (`dcIrParaVariacao()`) abre a thread no card da variação, rola até ele e dá um pulso (`dcVariacaoFoco`, 2,4s). Validar redesenha a tabela inteira (`renderAnaliseVertical()`), não só a lista, para o ícone mudar de cor junto.
- **Não aparecem na aba Observações** (pedido explícito do usuário, já estão aqui): `dcAchadosDaAbaObservacoes()` tira `variacao_atipica_dre` da lista, do donut e dos cards de categoria, e o grupo "Variações e Indicadores" saiu de `PID_DC_GRUPOS`. Continuam contando em `total_achados_pendentes` da listagem enquanto não validadas.
- **Não aparecem na aba Observações** (pedido explícito do usuário, já estão aqui): `dcAchadosDaAbaObservacoes()` tira `variacao_atipica_dre` da lista, do donut e dos cards de categoria, e o grupo "Variações e Indicadores" saiu de `PID_DC_GRUPOS`. Continuam contando em `total_achados_pendentes` da listagem enquanto não validadas. Para a aba não parecer vazia quando só há variações, ela mostra um card "Análise Vertical" no resumo (total + pendentes de validação, só quando há variação) e uma linha clicável abaixo da lista (`#dc-av-aviso`, `renderAvisoVariacoes()`); os dois levam, por `dcIrParaListaVariacoes()`, à aba Análise Vertical já rolada até esta lista (`#dc-av-variacoes`), sem filtrar a lista de Observações.
- **O texto não é `observacao_contador`**: decisão do usuário, "os textos devem funcionar da mesma maneira que as observações feitas nas contas". O botão de observação do card abre a thread de `ContabilObservacao` da **própria linha** da Análise Vertical (histórico entre competências, "mostrar ao cliente", encerrar/excluir). É a mesma thread da tabela: escrever pelo card aparece na linha e vice-versa, e no relatório a observação sai em "Observações da Análise Vertical" quando marcada para o cliente.
- A thread é montada por `dcObsPainelConteudoHtml(tipo, alvoId, observacoes, concluida, escopo)`: `escopo="variacao"` separa os ids dos campos e a chave em `dcObsPainelAberto` (`variacao`), e o atributo `data-dc-obs-criar` ganha um terceiro pedaço com o escopo. Abrir o painel do card fecha o da tabela e vice-versa, senão a mesma thread existiria duas vezes na tela com os mesmos ids de edição.

View File

@ -0,0 +1,40 @@
# Generated by Django 6.0.7 on 2026-09-30 14:47
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0086_conciliacao_vencidos_validados'),
]
operations = [
migrations.AlterField(
model_name='conciliacaofornecedorvinculohistorico',
name='acao',
field=models.CharField(choices=[('vinculou', 'Vinculou'), ('acrescentou', 'Acrescentou ao vínculo'), ('desfez', 'Desfez o vínculo'), ('removido', 'Desfeito na atualização do razão')], max_length=20, verbose_name='Ação'),
),
migrations.CreateModel(
name='ConciliacaoFornecedorAtualizacao',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('nome_arquivo', models.CharField(blank=True, max_length=255, verbose_name='Arquivo')),
('periodo_inicio', models.DateField(blank=True, null=True, verbose_name='Início do período')),
('periodo_fim', models.DateField(blank=True, null=True, verbose_name='Fim do período')),
('periodo_anterior_inicio', models.DateField(blank=True, null=True, verbose_name='Início do período anterior')),
('periodo_anterior_fim', models.DateField(blank=True, null=True, verbose_name='Fim do período anterior')),
('resumo', models.JSONField(blank=True, default=dict, verbose_name='Resumo')),
('criado_em', models.DateTimeField(auto_now_add=True)),
('conciliacao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='atualizacoes', to='portal_api.conciliacaofornecedor')),
('usuario', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Atualização do razão (Conciliação de fornecedores)',
'verbose_name_plural': 'Atualizações do razão (Conciliação de fornecedores)',
'ordering': ['-criado_em', '-id'],
},
),
]

View File

@ -2843,10 +2843,15 @@ class ConciliacaoFornecedorVinculoHistorico(models.Model):
ACAO_VINCULOU = "vinculou"
ACAO_ACRESCENTOU = "acrescentou"
ACAO_DESFEZ = "desfez"
# Vínculo manual desfeito pela atualização do razão (algum lançamento dele
# não veio no arquivo novo). Não é ajuste do contador: não conta para
# `_conciliacao_contas_ajustadas()` em views.py.
ACAO_REMOVIDO = "removido"
ACAO_CHOICES = [
(ACAO_VINCULOU, "Vinculou"),
(ACAO_ACRESCENTOU, "Acrescentou ao vínculo"),
(ACAO_DESFEZ, "Desfez o vínculo"),
(ACAO_REMOVIDO, "Desfeito na atualização do razão"),
]
conta = models.ForeignKey(ConciliacaoFornecedorConta, on_delete=models.CASCADE, related_name="historico_vinculos")
@ -2866,3 +2871,31 @@ class ConciliacaoFornecedorVinculoHistorico(models.Model):
def __str__(self) -> str:
return f"{self.get_acao_display()} #{self.vinculo_id_original} ({self.criado_em:%d/%m/%Y %H:%M})"
class ConciliacaoFornecedorAtualizacao(models.Model):
"""Registro de cada atualização do razão de uma conciliação (arquivo novo
que substitui o anterior, ver `conciliacao_fornecedores/atualizacao.py`).
`resumo` guarda as contagens do que foi mantido, entrou e saiu."""
conciliacao = models.ForeignKey(ConciliacaoFornecedor, on_delete=models.CASCADE, related_name="atualizacoes")
nome_arquivo = models.CharField("Arquivo", max_length=255, blank=True)
periodo_inicio = models.DateField("Início do período", null=True, blank=True)
periodo_fim = models.DateField("Fim do período", null=True, blank=True)
periodo_anterior_inicio = models.DateField("Início do período anterior", null=True, blank=True)
periodo_anterior_fim = models.DateField("Fim do período anterior", null=True, blank=True)
# {"lancamentos_mantidos", "lancamentos_novos", "lancamentos_removidos",
# "vinculos_mantidos", "vinculos_desfeitos", "vinculos_manuais_desfeitos",
# "vinculos_automaticos_novos", "contas_novas", "contas_removidas",
# "validacoes_retiradas", "revisoes_vencidos_retiradas"}
resumo = models.JSONField("Resumo", default=dict, blank=True)
usuario = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
criado_em = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Atualização do razão (Conciliação de fornecedores)"
verbose_name_plural = "Atualizações do razão (Conciliação de fornecedores)"
ordering = ["-criado_em", "-id"]
def __str__(self) -> str:
return f"{self.conciliacao_id} - {self.nome_arquivo} ({self.criado_em:%d/%m/%Y %H:%M})"

View File

@ -1,3 +1,4 @@
from datetime import date
from typing import Any
import nh3
@ -2794,6 +2795,19 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
class ConciliacaoFornecedorListSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
ultima_atualizacao = serializers.SerializerMethodField()
def get_ultima_atualizacao(self, obj: ConciliacaoFornecedor) -> dict[str, Any] | None:
# `atualizacoes` vem por prefetch na view (ordenado do mais recente).
atualizacoes = list(obj.atualizacoes.all())
if not atualizacoes:
return None
ultima = atualizacoes[0]
return {
"criado_em": ultima.criado_em.isoformat(),
"usuario_nome": ultima.usuario.nome if ultima.usuario else None,
"quantidade": len(atualizacoes),
}
class Meta:
model = ConciliacaoFornecedor
@ -2810,14 +2824,47 @@ class ConciliacaoFornecedorListSerializer(serializers.ModelSerializer):
"resumo",
"criado_por_nome",
"criado_em",
"ultima_atualizacao",
]
def _valida_arquivo_razao(arquivo: Any) -> Any:
nome = (arquivo.name or "").lower()
if not (nome.endswith(".xlsx") or nome.endswith(".csv")):
raise serializers.ValidationError("Anexe a exportação do razão em .xlsx ou .csv.")
if arquivo.size > CONCILIACAO_ARQUIVO_MAX_BYTES:
raise serializers.ValidationError("O arquivo deve ter no máximo 15MB.")
return arquivo
def _valida_periodo_fim_razao(valor: date) -> date:
if valor > timezone.localdate():
raise serializers.ValidationError("A data \"Razão até\" não pode ser futura.")
return valor
class ConciliacaoFornecedorAtualizarSerializer(serializers.Serializer):
"""Arquivo novo do razão, que substitui o da conciliação (a empresa é a
mesma, não se informa de novo). `periodo_fim`: até quando o razão foi
exportado, que o arquivo não traz (rodada 50 do CHANGELOG da app); a
conferência com os lançamentos fica na view, depois da leitura."""
arquivo = serializers.FileField()
periodo_fim = serializers.DateField()
def validate_arquivo(self, arquivo: Any) -> Any:
return _valida_arquivo_razao(arquivo)
def validate_periodo_fim(self, valor: date) -> date:
return _valida_periodo_fim_razao(valor)
class ConciliacaoFornecedorCreateSerializer(serializers.Serializer):
# Data-base (dia do processamento) e tolerâncias (motor.TOLERANCIA_*)
# são definidas no servidor, nunca aceitas da tela (decisão do usuário).
codigo_empresa = serializers.CharField(max_length=20)
arquivo = serializers.FileField()
periodo_fim = serializers.DateField()
def validate_codigo_empresa(self, valor: str) -> str:
codigo = normalizar_codigo_empresa(valor)
@ -2826,12 +2873,10 @@ class ConciliacaoFornecedorCreateSerializer(serializers.Serializer):
return codigo
def validate_arquivo(self, arquivo: Any) -> Any:
nome = (arquivo.name or "").lower()
if not (nome.endswith(".xlsx") or nome.endswith(".csv")):
raise serializers.ValidationError("Anexe a exportação do razão em .xlsx ou .csv.")
if arquivo.size > CONCILIACAO_ARQUIVO_MAX_BYTES:
raise serializers.ValidationError("O arquivo deve ter no máximo 15MB.")
return arquivo
return _valida_arquivo_razao(arquivo)
def validate_periodo_fim(self, valor: date) -> date:
return _valida_periodo_fim_razao(valor)
class ConciliacaoFornecedorVincularSerializer(serializers.Serializer):

View File

@ -8,6 +8,7 @@ import os
import re
import tempfile
import zipfile
from collections import Counter
from datetime import date, timedelta
from decimal import Decimal
from typing import Any, Callable, Iterable
@ -37,9 +38,11 @@ from rest_framework.serializers import ModelSerializer
from . import catalogo
from .conciliacao_fornecedores import alertas as conciliacao_alertas
from .conciliacao_fornecedores import atualizacao as conciliacao_atualizacao
from .conciliacao_fornecedores import exportacao as conciliacao_exportacao
from .conciliacao_fornecedores import motor as conciliacao_motor
from .conciliacao_fornecedores import parser as conciliacao_parser
from .conciliacao_fornecedores import relatorio as conciliacao_relatorio
from .custo_contratacao.calculo import calcula_custo_empregado
from .custo_contratacao.pdf import gera_pdf_simulacao
from .dashboard_contabil import chaves as dashboard_contabil_chaves
@ -62,6 +65,7 @@ from .models import (
CategoriaEvento,
CompromissoAgenda,
ConciliacaoFornecedor,
ConciliacaoFornecedorAtualizacao,
ConciliacaoFornecedorConta,
ConciliacaoFornecedorLancamento,
ConciliacaoFornecedorVinculo,
@ -141,6 +145,7 @@ from .serializers import (
AjudaAplicacaoSerializer,
CategoriaEventoSerializer,
CompromissoAgendaSerializer,
ConciliacaoFornecedorAtualizarSerializer,
ConciliacaoFornecedorCreateSerializer,
ConciliacaoFornecedorListSerializer,
ConciliacaoFornecedorValidarSerializer,
@ -5307,9 +5312,12 @@ def _conciliacao_analisa(
def _conciliacao_contas_ajustadas(conciliacao: ConciliacaoFornecedor) -> set[int]:
"""Contas com alguma ação manual sobre vínculo (vinculou, acrescentou ou
desfez), pelo histórico de vinculações."""
desfez), pelo histórico de vinculações. O vínculo desfeito pela
atualização do razão não é ajuste do contador."""
return set(
ConciliacaoFornecedorVinculoHistorico.objects.filter(conta__conciliacao=conciliacao).values_list("conta_id", flat=True)
ConciliacaoFornecedorVinculoHistorico.objects.filter(conta__conciliacao=conciliacao)
.exclude(acao=ConciliacaoFornecedorVinculoHistorico.ACAO_REMOVIDO)
.values_list("conta_id", flat=True)
)
@ -5512,13 +5520,57 @@ def _conciliacao_resposta_conta(conta: ConciliacaoFornecedorConta, sincroniza_va
conta = ConciliacaoFornecedorConta.objects.select_related("validada_por", "vencidos_validados_por").get(pk=conta.pk)
[carregada] = _conciliacao_carrega(conciliacao, [conta])
if sincroniza_validacao and _conciliacao_sincroniza_validacao_automatica(
conta, carregada[3], conta.historico_vinculos.exists()
conta,
carregada[3],
conta.historico_vinculos.exclude(acao=ConciliacaoFornecedorVinculoHistorico.ACAO_REMOVIDO).exists(),
):
conta.save(update_fields=["validada", "validada_por", "validada_em"])
resumo = _conciliacao_atualiza_resumo(conciliacao)
return Response({"conta": _conciliacao_conta_payload(*carregada), "resumo": resumo})
def _conciliacao_linhas_razao(
conta: ConciliacaoFornecedorConta,
extraida: conciliacao_parser.ContaExtraida,
razao: conciliacao_parser.RazaoExtraido,
data_base: date,
) -> list[ConciliacaoFornecedorLancamento]:
"""Lançamentos (não salvos) de uma conta do razão, na ordem do arquivo. O
saldo anterior diferente de zero vira o primeiro, `eh_saldo_anterior`."""
linhas: list[ConciliacaoFornecedorLancamento] = []
if extraida.saldo_inicial:
linhas.append(
ConciliacaoFornecedorLancamento(
conta=conta,
ordem=0,
eh_saldo_anterior=True,
data=extraida.data_saldo_inicial or razao.periodo_inicio or data_base,
historico="Saldo anterior",
natureza="C" if extraida.saldo_inicial > 0 else "D",
valor=abs(extraida.saldo_inicial),
saldo_apos=extraida.saldo_inicial,
)
)
for lanc in extraida.lancamentos:
linhas.append(
ConciliacaoFornecedorLancamento(
conta=conta,
ordem=len(linhas),
data=lanc.data,
sequencia=lanc.sequencia[:20],
historico=lanc.historico[:500],
contrapartida_codigo=lanc.contrapartida_codigo[:20],
contrapartida_descricao=lanc.contrapartida_descricao[:255],
natureza=lanc.natureza,
valor=lanc.valor,
saldo_apos=lanc.saldo_apos,
usuario=lanc.usuario[:100],
origem=lanc.origem[:10],
)
)
return linhas
def _conciliacao_grava_contas(
conciliacao: ConciliacaoFornecedor,
razao: conciliacao_parser.RazaoExtraido,
@ -5544,38 +5596,7 @@ def _conciliacao_grava_contas(
lancamentos: list[ConciliacaoFornecedorLancamento] = []
for conta, extraida in zip(contas, razao.contas):
linhas: list[ConciliacaoFornecedorLancamento] = []
if extraida.saldo_inicial:
linhas.append(
ConciliacaoFornecedorLancamento(
conta=conta,
ordem=0,
eh_saldo_anterior=True,
data=extraida.data_saldo_inicial or razao.periodo_inicio or conciliacao.data_base,
historico="Saldo anterior",
natureza="C" if extraida.saldo_inicial > 0 else "D",
valor=abs(extraida.saldo_inicial),
saldo_apos=extraida.saldo_inicial,
)
)
for lanc in extraida.lancamentos:
linhas.append(
ConciliacaoFornecedorLancamento(
conta=conta,
ordem=len(linhas),
data=lanc.data,
sequencia=lanc.sequencia[:20],
historico=lanc.historico[:500],
contrapartida_codigo=lanc.contrapartida_codigo[:20],
contrapartida_descricao=lanc.contrapartida_descricao[:255],
natureza=lanc.natureza,
valor=lanc.valor,
saldo_apos=lanc.saldo_apos,
usuario=lanc.usuario[:100],
origem=lanc.origem[:10],
)
)
linhas = _conciliacao_linhas_razao(conta, extraida, razao, conciliacao.data_base)
sugeridos = conciliacao_motor.concilia(
[
conciliacao_motor.ItemConciliacao(indice=pos, data=linha.data, natureza=linha.natureza, valor=linha.valor)
@ -5593,6 +5614,248 @@ def _conciliacao_grava_contas(
ConciliacaoFornecedorLancamento.objects.bulk_create(lancamentos, batch_size=2000)
def _conciliacao_vencidos_por_conta(conciliacao: ConciliacaoFornecedor) -> dict[int, set[int]]:
"""Itens na categoria "vencido" de cada conta (id do lançamento, ou
-id do vínculo para o saldo de pagamento parcial), na data-base atual da
conciliação."""
return {
conta.id: {
item_id
for item_id, situacao in analise.situacoes.items()
if situacao.categoria == conciliacao_alertas.CATEGORIA_VENCIDO
}
for conta, _, _, analise in _conciliacao_carrega(conciliacao)
}
def _conciliacao_atualiza_conta(
conciliacao: ConciliacaoFornecedor,
conta: ConciliacaoFornecedorConta,
extraida: conciliacao_parser.ContaExtraida,
razao: conciliacao_parser.RazaoExtraido,
antigos: list[ConciliacaoFornecedorLancamento],
vinculos: list[ConciliacaoFornecedorVinculo],
bloqueados: set[int],
usuario: Usuario,
contagem: Counter[str],
) -> None:
"""Substitui os lançamentos de uma conta pelos do arquivo novo. Lançamento
igual (mesma data, sequência, natureza e valor) é mantido com o mesmo id e
o vínculo que tinha; vínculo com algum lançamento que saiu do razão é
desfeito inteiro (o manual fica registrado no histórico). O motor roda de
novo só sobre o que ficou pendente, sem mexer nos vínculos mantidos nem nos
lançamentos que o contador tirou de um vínculo automático (`bloqueados`)."""
linhas_novas = _conciliacao_linhas_razao(conta, extraida, razao, conciliacao.data_base)
comparacao = conciliacao_atualizacao.compara(
[
conciliacao_atualizacao.LancamentoComparavel(l.id, l.eh_saldo_anterior, l.data, l.sequencia, l.natureza, l.valor)
for l in antigos
],
[
conciliacao_atualizacao.LancamentoComparavel(pos, l.eh_saldo_anterior, l.data, l.sequencia, l.natureza, l.valor)
for pos, l in enumerate(linhas_novas)
],
)
antigos_por_id = {l.id: l for l in antigos}
removidos = set(comparacao.removidos)
lancs_por_vinculo: dict[int, set[int]] = {}
for lanc in antigos:
if lanc.vinculo_id is not None:
lancs_por_vinculo.setdefault(lanc.vinculo_id, set()).add(lanc.id)
afetados = conciliacao_atualizacao.vinculos_afetados(lancs_por_vinculo, removidos)
for vinculo in vinculos:
if vinculo.id not in afetados:
continue
if vinculo.tipo in (conciliacao_motor.TIPO_MANUAL, conciliacao_motor.TIPO_PARCIAL):
_conciliacao_registra_historico(
conta,
ConciliacaoFornecedorVinculoHistorico.ACAO_REMOVIDO,
vinculo,
[antigos_por_id[i] for i in lancs_por_vinculo[vinculo.id]],
usuario,
)
contagem["vinculos_manuais_desfeitos"] += 1
contagem["vinculos_desfeitos"] += len(afetados)
contagem["vinculos_mantidos"] += len(lancs_por_vinculo) - len(afetados)
if afetados:
ConciliacaoFornecedorVinculo.objects.filter(id__in=afetados).delete()
if removidos:
ConciliacaoFornecedorLancamento.objects.filter(id__in=removidos).delete()
final: list[ConciliacaoFornecedorLancamento] = []
mantidos: list[ConciliacaoFornecedorLancamento] = []
criar: list[ConciliacaoFornecedorLancamento] = []
for pos, nova in enumerate(linhas_novas):
antigo_id = comparacao.mantidos.get(pos)
if antigo_id is None:
criar.append(nova)
final.append(nova)
continue
lanc = antigos_por_id[antigo_id]
# O que identifica o lançamento é igual; o resto vem do arquivo novo.
for campo in ("ordem", "historico", "contrapartida_codigo", "contrapartida_descricao", "saldo_apos", "usuario", "origem"):
setattr(lanc, campo, getattr(nova, campo))
if lanc.vinculo_id in afetados:
lanc.vinculo = None
mantidos.append(lanc)
final.append(lanc)
ConciliacaoFornecedorLancamento.objects.bulk_create(criar, batch_size=2000)
ConciliacaoFornecedorLancamento.objects.bulk_update(
mantidos,
["ordem", "historico", "contrapartida_codigo", "contrapartida_descricao", "saldo_apos", "usuario", "origem", "vinculo"],
batch_size=2000,
)
contagem["lancamentos_mantidos"] += len(mantidos)
contagem["lancamentos_novos"] += len(criar)
contagem["lancamentos_removidos"] += len(removidos)
fixos = frozenset(pos for pos, l in enumerate(final) if l.vinculo_id is not None or l.id in bloqueados)
sugeridos = conciliacao_motor.concilia(
[conciliacao_motor.ItemConciliacao(indice=pos, data=l.data, natureza=l.natureza, valor=l.valor) for pos, l in enumerate(final)],
fixos,
)
if sugeridos:
novos_vinculos = ConciliacaoFornecedorVinculo.objects.bulk_create(
[ConciliacaoFornecedorVinculo(conta=conta, tipo=s.tipo, diferenca=s.diferenca) for s in sugeridos]
)
atualizar: list[ConciliacaoFornecedorLancamento] = []
for vinculo, sugerido in zip(novos_vinculos, sugeridos):
for pos in sugerido.indices:
final[pos].vinculo = vinculo
atualizar.append(final[pos])
ConciliacaoFornecedorLancamento.objects.bulk_update(atualizar, ["vinculo"], batch_size=2000)
contagem["vinculos_automaticos_novos"] += len(sugeridos)
conta.ordem = extraida.ordem
conta.classificacao = extraida.classificacao
conta.fornecedor = extraida.fornecedor[:255]
conta.saldo_inicial = extraida.saldo_inicial
conta.saldo_final = extraida.saldo_final_calculado
conta.saldo_final_arquivo = extraida.saldo_final_arquivo
campos = ["ordem", "classificacao", "fornecedor", "saldo_inicial", "saldo_final", "saldo_final_arquivo"]
# Conta que mudou volta a "a validar" (a observação fica); a validação
# automática é reaplicada depois, se ainda couber (decisão do usuário).
if (criar or removidos) and conta.validada and conta.validada_por_id is not None:
conta.validada, conta.validada_por, conta.validada_em = False, None, None
campos += ["validada", "validada_por", "validada_em"]
contagem["validacoes_retiradas"] += 1
conta.save(update_fields=campos)
def _conciliacao_aplica_periodo_fim(
razao: conciliacao_parser.RazaoExtraido, periodo_fim: date, fim_atual: date | None = None
) -> None:
"""Troca o fim do período (último lançamento do arquivo) pela data "Razão
até" informada pelo contador: a exportação não traz o período, e um mês
sem movimento não pode encurtá-lo (rodada 50 do CHANGELOG da app)."""
fmt = "%d/%m/%Y"
ultimo = razao.ultimo_lancamento
if ultimo and periodo_fim < ultimo:
raise ValidationError(
{"periodo_fim": f"O arquivo tem lançamento em {ultimo:{fmt}}, depois da data \"Razão até\" informada."}
)
if razao.periodo_inicio and periodo_fim < razao.periodo_inicio:
raise ValidationError(
{"periodo_fim": f"O razão começa em {razao.periodo_inicio:{fmt}}, depois da data \"Razão até\" informada."}
)
if fim_atual and periodo_fim < fim_atual:
raise ValidationError(
{"periodo_fim": f"O razão atual já vai até {fim_atual:{fmt}}. Informe essa data ou uma posterior."}
)
razao.periodo_fim = periodo_fim
def _conciliacao_aplica_atualizacao(
conciliacao: ConciliacaoFornecedor, razao: conciliacao_parser.RazaoExtraido, usuario: Usuario
) -> dict[str, int]:
"""Substitui o razão da conciliação pelo do arquivo novo (ver
`conciliacao_fornecedores/atualizacao.py`). Não mexe em arquivo nem no
registro da atualização: isso é da view. Precisa rodar numa transação."""
antes_vencidos = _conciliacao_vencidos_por_conta(conciliacao)
existentes = list(conciliacao.contas.select_related("validada_por"))
if existentes and razao.contas:
numeros = {c.conta_numero for c in existentes}
if not any(extraida.conta_numero in numeros for extraida in razao.contas):
raise ValidationError(
{"arquivo": "Nenhuma conta do arquivo existe nesta conciliação. Confira se o razão é da mesma empresa."}
)
por_chave = {(c.conta_numero, c.classificacao): c for c in existentes}
por_numero: dict[str, list[ConciliacaoFornecedorConta]] = {}
for conta in existentes:
por_numero.setdefault(conta.conta_numero, []).append(conta)
ids = [c.id for c in existentes]
lancs_por_conta: dict[int, list[ConciliacaoFornecedorLancamento]] = {i: [] for i in ids}
for lanc in ConciliacaoFornecedorLancamento.objects.filter(conta_id__in=ids).order_by("ordem", "id"):
lancs_por_conta[lanc.conta_id].append(lanc)
vinc_por_conta: dict[int, list[ConciliacaoFornecedorVinculo]] = {i: [] for i in ids}
for vinc in ConciliacaoFornecedorVinculo.objects.filter(conta_id__in=ids):
vinc_por_conta[vinc.conta_id].append(vinc)
# Lançamentos que o contador tirou de um vínculo automático: o motor não
# volta a vinculá-los sozinho.
bloqueados_por_conta: dict[int, set[int]] = {}
for registro in ConciliacaoFornecedorVinculoHistorico.objects.filter(
conta_id__in=ids, acao=ConciliacaoFornecedorVinculoHistorico.ACAO_DESFEZ, vinculo_automatico=True
):
bloqueados_por_conta.setdefault(registro.conta_id, set()).update(l["id"] for l in registro.lancamentos)
contagem: Counter[str] = Counter()
usadas: set[int] = set()
for extraida in razao.contas:
conta = por_chave.get((extraida.conta_numero, extraida.classificacao))
if conta is None or conta.id in usadas:
conta = next((c for c in por_numero.get(extraida.conta_numero, []) if c.id not in usadas), None)
if conta is None:
conta = ConciliacaoFornecedorConta.objects.create(
conciliacao=conciliacao,
ordem=extraida.ordem,
conta_numero=extraida.conta_numero,
classificacao=extraida.classificacao,
fornecedor=extraida.fornecedor[:255],
saldo_inicial=extraida.saldo_inicial,
saldo_final=extraida.saldo_final_calculado,
saldo_final_arquivo=extraida.saldo_final_arquivo,
)
contagem["contas_novas"] += 1
usadas.add(conta.id)
_conciliacao_atualiza_conta(
conciliacao,
conta,
extraida,
razao,
lancs_por_conta.get(conta.id, []),
vinc_por_conta.get(conta.id, []),
bloqueados_por_conta.get(conta.id, set()),
usuario,
contagem,
)
sairam = [c.id for c in existentes if c.id not in usadas]
if sairam:
ConciliacaoFornecedorConta.objects.filter(id__in=sairam).delete()
contagem["contas_removidas"] = len(sairam)
conciliacao.data_base = timezone.localdate()
conciliacao.periodo_inicio = razao.periodo_inicio
conciliacao.periodo_fim = razao.periodo_fim
conciliacao.save(update_fields=["data_base", "periodo_inicio", "periodo_fim"])
# A revisão dos títulos antigos só é refeita se entrou título novo na lista
# (inclusive um que completou 3 meses com a data-base nova).
depois_vencidos = _conciliacao_vencidos_por_conta(conciliacao)
retirar = [
conta_id
for conta_id, itens in depois_vencidos.items()
if itens - antes_vencidos.get(conta_id, set())
]
contagem["revisoes_vencidos_retiradas"] = ConciliacaoFornecedorConta.objects.filter(
id__in=retirar, vencidos_validados=True
).update(vencidos_validados=False, vencidos_validados_por=None, vencidos_validados_em=None)
_conciliacao_valida_conciliadas(conciliacao)
_conciliacao_atualiza_resumo(conciliacao)
return dict(contagem)
def _conciliacao_gera_xlsx(conciliacao: ConciliacaoFornecedor) -> bytes:
contas_xlsx: list[conciliacao_exportacao.ContaXlsx] = []
pendencias: list[conciliacao_exportacao.PendenciaXlsx] = []
@ -5678,6 +5941,71 @@ def _conciliacao_gera_xlsx(conciliacao: ConciliacaoFornecedor) -> bytes:
)
def _conciliacao_monta_relatorio(conciliacao: ConciliacaoFornecedor) -> conciliacao_relatorio.RelatorioConciliacao:
"""Converte o carregado (contas + lançamentos + vínculos + análise) e o
histórico de vinculações nas estruturas puras de `relatorio.py`."""
historico_por_conta: dict[int, list[conciliacao_relatorio.HistoricoRelatorio]] = {}
for registro in ConciliacaoFornecedorVinculoHistorico.objects.filter(conta__conciliacao=conciliacao).select_related(
"usuario"
):
historico_por_conta.setdefault(registro.conta_id, []).append(
conciliacao_relatorio.HistoricoRelatorio(
acao=registro.acao,
vinculo_automatico=registro.vinculo_automatico,
usuario=registro.usuario.nome if registro.usuario else None,
criado_em=registro.criado_em,
)
)
contas: list[conciliacao_relatorio.ContaRelatorio] = []
for conta, lancs, vincs, analise in _conciliacao_carrega(conciliacao):
contas.append(
conciliacao_relatorio.ContaRelatorio(
conta_numero=conta.conta_numero,
fornecedor=conta.fornecedor,
saldo_inicial=conta.saldo_inicial,
saldo_final=conta.saldo_final,
saldo_final_arquivo=conta.saldo_final_arquivo,
analise=analise,
lancamentos=[
conciliacao_relatorio.LancamentoRelatorio(
id=lanc.id,
data=lanc.data,
natureza=lanc.natureza,
valor=lanc.valor,
vinculo_id=lanc.vinculo_id,
eh_saldo_anterior=lanc.eh_saldo_anterior,
sequencia=lanc.sequencia,
)
for lanc in lancs
],
vinculos=[
conciliacao_relatorio.VinculoRelatorio(
id=v.id,
tipo=v.tipo,
diferenca=v.diferenca,
data_titulo=min(
(lanc.data for lanc in lancs if lanc.vinculo_id == v.id and lanc.natureza == "C"), default=None
),
criado_por=v.criado_por.nome if v.criado_por else None,
)
for v in vincs
],
historico=historico_por_conta.get(conta.id, []),
validada=conta.validada,
validada_por=conta.validada_por.nome if conta.validada_por else None,
validada_em=conta.validada_em,
observacao=conta.observacao,
vencidos_validados=conta.vencidos_validados,
vencidos_validados_por=conta.vencidos_validados_por.nome if conta.vencidos_validados_por else None,
vencidos_validados_em=conta.vencidos_validados_em,
vencidos_observacao=conta.vencidos_observacao,
)
)
return conciliacao_relatorio.monta_relatorio(
contas, conciliacao.data_base, conciliacao.criado_por.nome if conciliacao.criado_por else None
)
class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
"""Histórico (GET lista, filtrável por `?codigo_empresa=`), criação
(multipart: só `codigo_empresa` + `arquivo`; data-base e tolerâncias
@ -5690,7 +6018,7 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
return [PermissaoApp(*_CONCILIACAO_PERMISSAO)]
def get_queryset(self) -> QuerySet[ConciliacaoFornecedor]:
queryset = ConciliacaoFornecedor.objects.select_related("criado_por")
queryset = ConciliacaoFornecedor.objects.select_related("criado_por").prefetch_related("atualizacoes__usuario")
codigo = normalizar_codigo_empresa(self.request.query_params.get("codigo_empresa") or "")
if codigo:
queryset = queryset.filter(codigo_empresa=codigo)
@ -5724,12 +6052,20 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
entrada = ConciliacaoFornecedorCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
# Uma conciliação por empresa (decisão do usuário, rodada 48 do
# CHANGELOG da app): o mês seguinte é uma atualização, não outra.
existente = ConciliacaoFornecedor.objects.filter(codigo_empresa=dados["codigo_empresa"]).first()
if existente is not None:
raise ValidationError(
{"codigo_empresa": "Esta empresa já tem uma conciliação. Use \"Atualizar\" nela para anexar o razão novo."}
)
arquivo = dados["arquivo"]
conteudo = arquivo.read()
try:
razao = conciliacao_parser.extrai_razao(conteudo, arquivo.name)
except conciliacao_parser.ArquivoRazaoInvalidoError as exc:
raise ValidationError({"arquivo": str(exc)})
_conciliacao_aplica_periodo_fim(razao, dados["periodo_fim"])
tolerancia = conciliacao_motor.Tolerancia(
conciliacao_motor.TOLERANCIA_VALOR_PADRAO, conciliacao_motor.TOLERANCIA_PERCENTUAL_PADRAO
@ -5790,6 +6126,71 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
resposta["Content-Disposition"] = f'attachment; filename="{nome}"'
return resposta
@action(detail=True, methods=["post"])
def atualizar(self, request: Request, pk: str | None = None) -> Response:
"""Anexa o razão atualizado: o arquivo novo substitui o da conciliação
(ver `_conciliacao_aplica_atualizacao()`), e a análise é refeita sobre
ele. Devolve a conciliação e as contagens do que mudou."""
conciliacao = self.get_object()
entrada = ConciliacaoFornecedorAtualizarSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
arquivo = entrada.validated_data["arquivo"]
conteudo = arquivo.read()
try:
razao = conciliacao_parser.extrai_razao(conteudo, arquivo.name)
except conciliacao_parser.ArquivoRazaoInvalidoError as exc:
raise ValidationError({"arquivo": str(exc)})
_conciliacao_aplica_periodo_fim(razao, entrada.validated_data["periodo_fim"], conciliacao.periodo_fim)
storage = conciliacao.arquivo.storage
arquivo_antigo = conciliacao.arquivo.name
arquivo_novo: str | None = None
try:
with transaction.atomic():
conciliacao = ConciliacaoFornecedor.objects.select_for_update().get(pk=conciliacao.pk)
periodo_anterior = (conciliacao.periodo_inicio, conciliacao.periodo_fim)
contagem = _conciliacao_aplica_atualizacao(conciliacao, razao, request.user)
conciliacao.arquivo.save(arquivo.name, ContentFile(conteudo), save=False)
arquivo_novo = conciliacao.arquivo.name
conciliacao.nome_arquivo = arquivo.name[:255]
conciliacao.save(update_fields=["arquivo", "nome_arquivo"])
ConciliacaoFornecedorAtualizacao.objects.create(
conciliacao=conciliacao,
nome_arquivo=arquivo.name[:255],
periodo_inicio=razao.periodo_inicio,
periodo_fim=razao.periodo_fim,
periodo_anterior_inicio=periodo_anterior[0],
periodo_anterior_fim=periodo_anterior[1],
resumo=contagem,
usuario=request.user,
)
except Exception:
# upload de FileField não é transacional
if arquivo_novo:
storage.delete(arquivo_novo)
raise
if arquivo_antigo and arquivo_antigo != arquivo_novo:
storage.delete(arquivo_antigo)
conciliacao = self.get_queryset().get(pk=conciliacao.pk)
return Response({"conciliacao": ConciliacaoFornecedorListSerializer(conciliacao).data, "atualizacao": contagem})
@action(detail=True, methods=["get"])
def relatorio(self, request: Request, pk: str | None = None) -> HttpResponse:
"""Relatório de análise para o contador (decisões tomadas e situações
de risco): HTML autocontido com a marca do escritório, aberto em nova
aba pela tela (GET, mesmo motivo de `ContabilApuracaoViewSet.dashboard`).
Só leitura, pode ser gerado com a conciliação em andamento."""
conciliacao = self.get_object()
contexto = {
"conciliacao": conciliacao,
"relatorio": _conciliacao_monta_relatorio(conciliacao),
"meses_vencido": conciliacao_alertas.MESES_VENCIDO,
"janela_duplicidade": conciliacao_relatorio.JANELA_DIAS_DUPLICIDADE,
"gerado_em": timezone.now(),
}
html = render_to_string("conciliacao-fornecedores-relatorio.html", contexto)
return HttpResponse(html, content_type="text/html; charset=utf-8")
def _conciliacao_tipo_vinculo_manual(
conciliacao: ConciliacaoFornecedor, lancs: list[ConciliacaoFornecedorLancamento]

View File

@ -165,6 +165,11 @@
z-index: 150;
}
/* Mensagem de pidConfirm/pidAlert com várias linhas ("\n") */
.confirm-modal__mensagem {
white-space: pre-line;
}
.modal-card {
width: 100%;
max-width: 420px;

View File

@ -84,6 +84,52 @@
min-height: 1.2em;
}
/* Campo "Razão até": ícone/popup nativo do calendário seguindo o tema (mesmo
ajuste do input de competência do Indicador de Desempenho). */
#conc-form-periodo-fim,
#conc-atualizar-periodo-fim {
color-scheme: dark;
cursor: pointer;
}
:root[data-theme="light"] #conc-form-periodo-fim,
:root[data-theme="light"] #conc-atualizar-periodo-fim {
color-scheme: light;
}
/* Barra de progresso de "Nova Conciliação"/"Atualizar razão": mesmo padrão
do Relatório Contábil (.dc-progress). O processamento é uma requisição
síncrona, sem percentual real: a subida até ~90% é simulada em JS
(pidConcIniciarProgresso) e só fecha quando a resposta volta. */
.conc-progress__bar {
width: 100%;
height: 6px;
border-radius: var(--radius-pill);
background: var(--border-subtle);
overflow: hidden;
}
.conc-progress__fill {
height: 100%;
width: 0%;
border-radius: inherit;
background: var(--accent);
transition: width 0.2s ease-out;
}
.conc-progress__label {
margin-top: var(--space-2);
font-size: 0.85rem;
color: var(--text-muted);
text-align: center;
}
.conc-form__dica {
margin: var(--space-1) 0 0;
font-size: 0.78rem;
color: var(--text-secondary);
}
.conc-form__empresa.is-erro {
color: var(--danger);
}
@ -143,11 +189,20 @@
margin-bottom: var(--space-4);
}
.conc-empresa .btn-outline {
.conc-empresa__acoes {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-2);
}
.conc-empresa .btn-outline,
.conc-empresa .btn-solid {
display: inline-flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
text-decoration: none;
}
.conc-empresa__codigo {
@ -835,6 +890,46 @@ button.conc-kpi.is-active {
border-left-color: var(--danger);
}
/* desfeito pela atualização do razão (não foi ação do contador) */
.conc-historico-vinc__item--removido {
border-left-color: #c6a24a;
border-left-style: dashed;
}
/* Modal "Atualizar razão" */
.conc-atualizar-info {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.conc-atualizar-info p {
margin: 0 0 var(--space-2);
}
.conc-atualizar-info ul {
margin: 0 0 var(--space-3);
padding-left: var(--space-5);
}
.conc-atualizar-info li {
margin-bottom: var(--space-1);
}
.conc-atualizar-info strong {
color: var(--text-primary);
}
.conc-atualizar-info__atual {
color: var(--text-primary);
}
.conc-lista-acoes {
display: inline-flex;
gap: var(--space-2);
justify-content: flex-end;
}
.conc-historico-vinc__cabecalho {
display: flex;
align-items: baseline;

View File

@ -658,6 +658,42 @@
gap: var(--space-3);
}
/* Linha abaixo da lista de Observações apontando as variações atípicas da
Análise Vertical (que não entram nesta lista); clique leva à aba dela. */
.dc-av-aviso {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
margin-top: var(--space-3);
padding: var(--space-3) var(--space-4);
font-size: 0.85rem;
text-align: left;
color: var(--text-secondary);
background: var(--bg-surface-raised);
border: 1px solid var(--border-subtle);
border-left: 4px solid rgb(var(--teal-rgb));
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--transition-fast), color var(--transition-fast);
}
.dc-av-aviso:hover {
color: var(--text-primary);
border-color: rgb(var(--teal-rgb));
}
.dc-av-aviso__texto {
flex: 1 1 auto;
min-width: 0;
}
.dc-av-aviso__link {
flex-shrink: 0;
color: var(--accent);
font-weight: 600;
}
.dc-achado-card {
border: 1px solid var(--border-subtle);
border-left-width: 4px;

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -241,6 +241,47 @@ function pidConcData(iso) {
return `${dia}/${mes}/${ano}`;
}
function pidConcIsoLocal(data) {
const mes = String(data.getMonth() + 1).padStart(2, "0");
const dia = String(data.getDate()).padStart(2, "0");
return `${data.getFullYear()}-${mes}-${dia}`;
}
/** Sugestão do campo "Razão até": último dia do mês anterior (o normal é
* exportar o razão depois do fechamento do mês), nunca antes de `minimo`. */
function pidConcRazaoAtePadrao(minimo) {
const hoje = new Date();
const padrao = pidConcIsoLocal(new Date(hoje.getFullYear(), hoje.getMonth(), 0));
return minimo && minimo > padrao ? minimo : padrao;
}
/** Barra de progresso de "Nova Conciliação"/"Atualizar razão" (mesmo padrão
* de `pidDcIniciarProgressoSimulado` do Relatório Contábil): o processamento
* é uma requisição síncrona, sem percentual real. Sobe rápido e desacelera
* perto de 90%; só `completar()`, quando a resposta volta, chega a 100%. */
function pidConcIniciarProgresso(progressEl) {
const fillEl = progressEl.querySelector(".conc-progress__fill");
let pct = 0;
fillEl.style.width = "0%";
progressEl.hidden = false;
const intervalId = setInterval(() => {
pct += (90 - pct) * 0.1;
fillEl.style.width = `${pct}%`;
}, 200);
return {
parar() {
clearInterval(intervalId);
progressEl.hidden = true;
},
async completar() {
clearInterval(intervalId);
fillEl.style.width = "100%";
await new Promise((resolve) => setTimeout(resolve, 300));
progressEl.hidden = true;
},
};
}
function pidConcDataHora(iso) {
if (!iso) return "—";
const data = new Date(iso);
@ -316,6 +357,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const empresaEl = document.getElementById("conc-form-empresa");
const arquivoInput = document.getElementById("conc-form-arquivo");
const arquivoNomeEl = document.getElementById("conc-form-arquivo-nome");
const periodoFimInput = document.getElementById("conc-form-periodo-fim");
const formErroEl = document.getElementById("conc-form-erro");
const processarBtn = document.getElementById("conc-form-processar-btn");
const listaBody = document.getElementById("conc-lista-body");
@ -333,6 +375,8 @@ document.addEventListener("DOMContentLoaded", async () => {
empresaEl.className = "conc-form__empresa";
arquivoInput.value = "";
arquivoNomeEl.textContent = "Nenhum arquivo selecionado";
periodoFimInput.value = pidConcRazaoAtePadrao();
periodoFimInput.max = pidConcIsoLocal(new Date());
formErroEl.textContent = "";
nomeEmpresaResolvido = null;
}
@ -407,7 +451,24 @@ document.addEventListener("DOMContentLoaded", async () => {
formErroEl.textContent = "Selecione o arquivo do razão.";
return;
}
if (!periodoFimInput.value) {
formErroEl.textContent = "Informe até quando o razão foi exportado (\"Razão até\").";
return;
}
clearTimeout(timerNomeEmpresa);
// Uma conciliação por empresa: o razão novo vai para a existente.
const codigoCanonico = codigo.replace(/^0+(?=\d)/, ""); // mesmo critério de normalizar_codigo_empresa()
const existente = historico.find((item) => String(item.codigo_empresa) === codigoCanonico);
if (existente) {
const atualizar = await pidConfirm(
`A empresa ${codigo} já tem uma conciliação (${existente.periodo_inicio ? `${pidConcData(existente.periodo_inicio)} a ${pidConcData(existente.periodo_fim)}` : "sem lançamentos"}). O razão novo substitui o dela. Atualizar essa conciliação com este arquivo?`,
{ titulo: "Empresa já conciliada", textoConfirmar: "Atualizar a existente" }
);
if (!atualizar) return;
formEl.hidden = true;
abrirAtualizar(existente, arquivo, periodoFimInput.value);
return;
}
if (nomeEmpresaResolvido === null) await buscarNomeEmpresa();
if (!nomeEmpresaResolvido) {
const seguir = await pidConfirm(
@ -420,18 +481,25 @@ document.addEventListener("DOMContentLoaded", async () => {
const dados = new FormData();
dados.append("codigo_empresa", codigo);
dados.append("arquivo", arquivo);
dados.append("periodo_fim", periodoFimInput.value);
const cancelarBtn = document.getElementById("conc-form-cancelar-btn");
processarBtn.disabled = true;
cancelarBtn.disabled = true;
processarBtn.textContent = "Processando...";
const progresso = pidConcIniciarProgresso(document.getElementById("conc-form-progress"));
try {
const criada = await pidApiRequest("/conciliacoes-fornecedores/", { method: "POST", body: dados });
await progresso.completar();
formEl.hidden = true;
await carregarHistorico();
abrirDetalhe(criada.id);
} catch (erro) {
progresso.parar();
formErroEl.textContent = erro.message;
} finally {
processarBtn.disabled = false;
cancelarBtn.disabled = false;
processarBtn.textContent = "Processar";
}
});
@ -459,12 +527,20 @@ document.addEventListener("DOMContentLoaded", async () => {
<td class="conc-num">${conciliados}</td>
<td class="conc-num">${porStatus.divergente ? `<span class="conc-valor--devedor">${porStatus.divergente}</span>` : 0}</td>
<td class="conc-num conc-valor">${pidConcMoeda(resumo.total_em_aberto)}</td>
<td>${pidConcEscape(item.criado_por_nome || "—")}<span class="conc-fornecedor__alerta">${pidConcDataHora(item.criado_em)}</span></td>
<td class="conc-num"><button type="button" class="btn-danger-outline conc-btn-sm" data-excluir>Excluir</button></td>`;
<td>${pidConcEscape(item.criado_por_nome || "—")}<span class="conc-fornecedor__alerta">${pidConcDataHora(item.criado_em)}${
item.ultima_atualizacao ? ` · atualizado em ${pidConcDataHora(item.ultima_atualizacao.criado_em)}` : ""
}</span></td>
<td class="conc-num">
<span class="conc-lista-acoes">
<button type="button" class="btn-outline conc-btn-sm" data-atualizar>Atualizar</button>
<button type="button" class="btn-danger-outline conc-btn-sm" data-excluir>Excluir</button>
</span>
</td>`;
tr.addEventListener("click", (evento) => {
if (evento.target.closest("[data-excluir]")) return;
if (evento.target.closest("[data-excluir], [data-atualizar]")) return;
abrirDetalhe(item.id);
});
tr.querySelector("[data-atualizar]").addEventListener("click", () => abrirAtualizar(item));
tr.querySelector("[data-excluir]").addEventListener("click", async () => {
const confirmado = await pidConfirm(
`Excluir a conciliação de ${item.codigo_empresa} - ${item.nome_empresa || ""} (data-base ${pidConcData(item.data_base)})? Os vínculos manuais e validações feitos nela também serão excluídos.`,
@ -496,6 +572,130 @@ document.addEventListener("DOMContentLoaded", async () => {
renderHistorico();
}
// ======================================================================
// Atualizar o razão (arquivo novo substitui o da conciliação)
// ======================================================================
const atualizarModal = document.getElementById("conc-atualizar-modal");
const atualizarArquivoInput = document.getElementById("conc-atualizar-arquivo");
const atualizarArquivoNome = document.getElementById("conc-atualizar-arquivo-nome");
const atualizarPeriodoFim = document.getElementById("conc-atualizar-periodo-fim");
const atualizarErro = document.getElementById("conc-atualizar-erro");
const atualizarBtn = document.getElementById("conc-atualizar-processar-btn");
let atualizarAlvo = null;
function periodoTexto(item) {
return item.periodo_inicio ? `${pidConcData(item.periodo_inicio)} a ${pidConcData(item.periodo_fim)}` : "sem lançamentos";
}
/** `arquivoInicial`/`periodoFimInicial`: arquivo e "Razão até" já escolhidos
* em "Nova Conciliação" para uma empresa que já tinha conciliação. */
function abrirAtualizar(item, arquivoInicial, periodoFimInicial) {
atualizarAlvo = item;
document.getElementById("conc-atualizar-empresa").textContent =
`${item.codigo_empresa} - ${item.nome_empresa || "Empresa não encontrada no Questor"}`;
document.getElementById("conc-atualizar-periodo").innerHTML =
`Período atual do razão: <strong>${periodoTexto(item)}</strong>`;
atualizarArquivoInput.value = "";
if (arquivoInicial) {
const transferencia = new DataTransfer();
transferencia.items.add(arquivoInicial);
atualizarArquivoInput.files = transferencia.files;
}
atualizarArquivoNome.textContent = arquivoInicial ? arquivoInicial.name : "Nenhum arquivo selecionado";
// O razão novo não pode terminar antes do atual.
atualizarPeriodoFim.min = item.periodo_fim || "";
atualizarPeriodoFim.max = pidConcIsoLocal(new Date());
atualizarPeriodoFim.value =
periodoFimInicial && (!item.periodo_fim || periodoFimInicial >= item.periodo_fim)
? periodoFimInicial
: pidConcRazaoAtePadrao(item.periodo_fim);
atualizarErro.textContent = "";
atualizarModal.hidden = false;
}
function fecharAtualizar() {
if (atualizarBtn.disabled) return; // processando
atualizarModal.hidden = true;
atualizarAlvo = null;
}
atualizarArquivoInput.addEventListener("change", () => {
const arquivo = atualizarArquivoInput.files[0];
atualizarArquivoNome.textContent = arquivo ? arquivo.name : "Nenhum arquivo selecionado";
});
document.getElementById("conc-atualizar-cancelar-btn").addEventListener("click", fecharAtualizar);
atualizarModal.addEventListener("click", (evento) => {
if (evento.target === atualizarModal) fecharAtualizar();
});
document.getElementById("conc-det-atualizar-btn").addEventListener("click", () => {
if (detalhe) abrirAtualizar(detalhe);
});
function resumoAtualizacaoTexto(r) {
const n = (chave) => r[chave] || 0;
const linhas = [
`Período do razão: ${periodoTexto(atualizarAlvo)} → ${r.periodo_novo}.`,
n("lancamentos_novos") || n("lancamentos_removidos")
? `Lançamentos: ${n("lancamentos_mantidos")} mantidos, ${n("lancamentos_novos")} novos, ${n("lancamentos_removidos")} fora do arquivo novo.`
: `Lançamentos: nenhum lançamento novo no período (${n("lancamentos_mantidos")} mantidos).`,
`Vínculos: ${n("vinculos_mantidos")} mantidos, ${n("vinculos_desfeitos")} desfeitos (${n("vinculos_manuais_desfeitos")} manuais), ${n("vinculos_automaticos_novos")} automáticos novos.`,
];
if (n("contas_novas") || n("contas_removidas")) {
linhas.push(`Fornecedores: ${n("contas_novas")} novos, ${n("contas_removidas")} fora do arquivo novo.`);
}
if (n("validacoes_retiradas") || n("revisoes_vencidos_retiradas")) {
linhas.push(
`A validar de novo: ${n("validacoes_retiradas")} conta(s) e ${n("revisoes_vencidos_retiradas")} revisão(ões) de títulos antigos.`
);
}
return linhas.join("\n");
}
atualizarBtn.addEventListener("click", async () => {
atualizarErro.textContent = "";
const arquivo = atualizarArquivoInput.files[0];
if (!arquivo) {
atualizarErro.textContent = "Selecione o arquivo do razão atualizado.";
return;
}
if (!atualizarPeriodoFim.value) {
atualizarErro.textContent = "Informe até quando o razão foi exportado (\"Razão até\").";
return;
}
const alvo = atualizarAlvo;
const dados = new FormData();
dados.append("arquivo", arquivo);
dados.append("periodo_fim", atualizarPeriodoFim.value);
const cancelarBtn = document.getElementById("conc-atualizar-cancelar-btn");
atualizarBtn.disabled = true;
cancelarBtn.disabled = true;
atualizarBtn.textContent = "Atualizando...";
const progresso = pidConcIniciarProgresso(document.getElementById("conc-atualizar-progress"));
let resposta;
try {
resposta = await pidApiRequest(`/conciliacoes-fornecedores/${alvo.id}/atualizar/`, { method: "POST", body: dados });
await progresso.completar();
} catch (erro) {
progresso.parar();
atualizarErro.textContent = erro.message;
return;
} finally {
atualizarBtn.disabled = false;
cancelarBtn.disabled = false;
atualizarBtn.textContent = "Atualizar";
}
fecharAtualizar();
atualizarAlvo = alvo; // para o texto do período anterior
const texto = resumoAtualizacaoTexto({ ...resposta.atualizacao, periodo_novo: periodoTexto(resposta.conciliacao) });
atualizarAlvo = null;
if (!detailView.hidden && detalhe && detalhe.id === alvo.id) {
await abrirDetalhe(alvo.id);
} else {
await carregarHistorico();
}
pidAlert(texto, { titulo: "Razão atualizado" });
});
// ======================================================================
// Detalhe
// ======================================================================
@ -576,8 +776,14 @@ document.addEventListener("DOMContentLoaded", async () => {
<span>Data-base <strong>${pidConcData(detalhe.data_base)}</strong></span>
<span>Período do razão <strong>${periodo}</strong></span>
<span>Arquivo <strong>${pidConcEscape(detalhe.nome_arquivo || "—")}</strong></span>
<span>Processado por <strong>${pidConcEscape(detalhe.criado_por_nome || "—")}</strong> em ${pidConcDataHora(detalhe.criado_em)}</span>`;
<span>Processado por <strong>${pidConcEscape(detalhe.criado_por_nome || "—")}</strong> em ${pidConcDataHora(detalhe.criado_em)}</span>
${
detalhe.ultima_atualizacao
? `<span>Razão atualizado por <strong>${pidConcEscape(detalhe.ultima_atualizacao.usuario_nome || "—")}</strong> em ${pidConcDataHora(detalhe.ultima_atualizacao.criado_em)}</span>`
: ""
}`;
document.getElementById("conc-exportar-btn").href = `${PID_API_BASE}/conciliacoes-fornecedores/${detalhe.id}/exportar-xlsx/`;
document.getElementById("conc-relatorio-btn").href = `${PID_API_BASE}/conciliacoes-fornecedores/${detalhe.id}/relatorio/`;
}
function contasComAlerta(tipo) {

View File

@ -1285,8 +1285,73 @@ document.addEventListener("DOMContentLoaded", async () => {
grid.appendChild(card);
});
// Card "Análise Vertical": as variações atípicas não entram na lista desta
// aba (ver dcAchadosDaAbaObservacoes), mas sem nenhum sinal aqui uma
// análise que só tem esse tipo de apontamento parecia vazia (pedido
// explícito do usuário). Só aparece quando há variação; o clique leva à
// lista no fim da aba Análise Vertical, nunca filtra esta lista.
const variacoes = dcVariacoesAnaliseVertical();
if (variacoes.length === 0) return;
const pendentes = variacoes.filter((a) => a.status === "pendente").length;
const cardAv = document.createElement("div");
cardAv.className = "dc-categoria-card";
cardAv.style.setProperty("--dc-categoria-cor", PID_DC_CATEGORIA_CORES[PID_DC_GRUPOS.length % PID_DC_CATEGORIA_CORES.length]);
cardAv.innerHTML = `
<div class="dc-categoria-card__header">
<span class="dc-categoria-card__dot"></span>
<span class="dc-categoria-card__titulo">Análise Vertical</span>
<span class="dc-categoria-card__contagem">${variacoes.length}</span>
</div>
<div class="dc-categoria-card__regras">
<div class="dc-categoria-card__regra dc-categoria-card__regra--clicavel" data-dc-ir-variacoes>
<span class="dc-categoria-card__regra-label">Variação atípica na DRE</span>
<span class="dc-categoria-card__regra-contagem">${variacoes.length}</span>
</div>
<div class="dc-categoria-card__regra dc-categoria-card__regra--clicavel" data-dc-ir-variacoes>
<span class="dc-categoria-card__regra-label">Pendentes de validação</span>
<span class="dc-categoria-card__regra-contagem">${pendentes}</span>
</div>
</div>
`;
cardAv.querySelectorAll("[data-dc-ir-variacoes]").forEach((linha) => {
linha.addEventListener("click", dcIrParaListaVariacoes);
});
grid.appendChild(cardAv);
}
function dcVariacoesAnaliseVertical() {
return (apuracaoAtual.achados || []).filter((a) => a.regra === "variacao_atipica_dre");
}
// Troca pra aba Análise Vertical e rola até a lista de variações no fim dela
// (mesmo mecanismo de troca de aba de dcIrParaLinha: clique no próprio botão
// da aba).
function dcIrParaListaVariacoes() {
const tabBtn = document.querySelector('#dc-tabs [data-dc-tab="analise-vertical"]');
if (tabBtn) tabBtn.click();
requestAnimationFrame(() => {
document.getElementById("dc-av-variacoes")?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
// Linha de aviso abaixo da lista: repete a informação do card "Análise
// Vertical" onde o olho do usuário cai quando a lista está vazia.
function renderAvisoVariacoes() {
const aviso = document.getElementById("dc-av-aviso");
const variacoes = dcVariacoesAnaliseVertical();
aviso.hidden = variacoes.length === 0;
if (aviso.hidden) return;
const pendentes = variacoes.filter((a) => a.status === "pendente").length;
const total = variacoes.length;
let texto = `${total} ${total === 1 ? "apontamento" : "apontamentos"} de variação atípica na Análise Vertical`;
if (pendentes > 0) texto += ` (${pendentes} ${pendentes === 1 ? "pendente" : "pendentes"} de validação)`;
else texto += " (todos validados)";
document.getElementById("dc-av-aviso-texto").textContent = `${texto}.`;
}
document.getElementById("dc-av-aviso").addEventListener("click", dcIrParaListaVariacoes);
function renderAchados() {
renderAchadosResumo();
@ -1300,6 +1365,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const achados = achadosFiltrados();
lista.innerHTML = "";
empty.hidden = achados.length > 0;
renderAvisoVariacoes();
achados.forEach((achado) => {
const card = document.createElement("div");
@ -2781,6 +2847,9 @@ document.addEventListener("DOMContentLoaded", async () => {
// A tabela inteira, não só a lista: o ícone de variação da linha muda
// de cor junto (renderAnaliseVertical já redesenha a lista no fim).
renderAnaliseVertical();
// Card "Análise Vertical" e aviso da aba Observações mostram quantas
// variações ainda estão pendentes.
renderAchados();
await carregarLista();
} catch (e) {
botao.disabled = false;

View File

@ -0,0 +1,718 @@
{% load static %}
{% load contabil_extras %}
<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Conciliação de Fornecedores · {{ conciliacao.codigo_empresa }} {{ conciliacao.nome_empresa }}</title>
<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&display=swap" rel="stylesheet">
<style>
/* Documento gerado: paleta fixa do escritório (mesma do relatório do
Relatório Contábil), sem tema claro/escuro do Portal. */
:root {
--roxo-escuro: #3d2178;
--roxo-mais-escuro: #281552;
--roxo-claro: #efe8ff;
--roxo-medio: #7a5bc0;
--dourado: #b4872a;
--dourado-claro: #f6ecd4;
--dourado-vivo: #d9ac48;
--texto: #211d2b;
--texto-muted: #6b6478;
--borda: #e6e1f0;
--bg-canvas: #f5f3fa;
--sucesso-bg: #dcf5e3;
--sucesso-texto: #1a7a3d;
--falha-bg: #fbe2e2;
--falha-texto: #b3261e;
--aviso-bg: #fdf0d8;
--aviso-texto: #9a6a0a;
--neutro-bg: #eeecf3;
--sombra: 0 1px 2px rgba(40, 21, 82, 0.04), 0 8px 24px -12px rgba(40, 21, 82, 0.18);
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(circle at 15% -10%, rgba(122, 91, 192, 0.14), transparent 42%),
radial-gradient(circle at 100% 0%, rgba(180, 135, 42, 0.10), transparent 38%),
var(--bg-canvas);
color: var(--texto);
font-family: "Inter", "Segoe UI", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, .cfr-card__valor, .cfr-parecer__nivel { font-family: "Manrope", "Inter", sans-serif; }
@keyframes cfrFadeSlideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.cfr-page { max-width: 1140px; margin: 0 auto; padding: 0 24px 56px; }
/* ---- Cabeçalho ---- */
.cfr-header {
background: linear-gradient(135deg, var(--roxo-escuro) 0%, var(--roxo-mais-escuro) 100%);
color: #fff;
padding: 28px;
margin: 0 -24px 24px;
display: flex;
align-items: center;
gap: 22px;
flex-wrap: wrap;
border-bottom: 3px solid var(--dourado);
}
.cfr-header__brand { display: flex; align-items: center; gap: 18px; }
.cfr-header img { height: 52px; width: auto; }
.cfr-header h1 { margin: 0; font-size: 1.35rem; font-weight: 800; }
.cfr-header p { margin: 4px 0 0; color: var(--dourado-claro); font-size: 0.86rem; }
.cfr-print-btn {
margin-left: auto;
background: linear-gradient(135deg, var(--dourado-vivo), var(--dourado));
color: var(--roxo-mais-escuro);
border: none;
border-radius: 8px;
padding: 11px 20px;
font: inherit;
font-size: 0.86rem;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: transform 160ms ease;
}
.cfr-print-btn:hover { transform: translateY(-2px); }
.cfr-print-btn:active { transform: scale(0.97); }
/* ---- Aviso de conciliação em andamento ---- */
.cfr-aviso {
display: flex;
gap: 12px;
align-items: flex-start;
background: var(--aviso-bg);
color: var(--aviso-texto);
border: 1px solid #f0d9a8;
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 20px;
font-weight: 600;
}
.cfr-aviso svg { flex-shrink: 0; margin-top: 2px; }
/* ---- Parecer ---- */
.cfr-parecer {
display: grid;
grid-template-columns: 240px 1fr;
gap: 20px;
background: #fff;
border: 1px solid var(--borda);
border-radius: 16px;
padding: 20px;
box-shadow: var(--sombra);
margin-bottom: 20px;
animation: cfrFadeSlideUp 360ms ease both;
}
.cfr-parecer__risco {
border-radius: 12px;
padding: 18px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.cfr-parecer__risco--alto { background: var(--falha-bg); color: var(--falha-texto); }
.cfr-parecer__risco--moderado { background: var(--aviso-bg); color: var(--aviso-texto); }
.cfr-parecer__risco--baixo { background: var(--sucesso-bg); color: var(--sucesso-texto); }
.cfr-parecer__rotulo { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.cfr-parecer__nivel { font-size: 1.9rem; font-weight: 800; line-height: 1.1; }
.cfr-parecer__nota { font-size: 0.74rem; opacity: 0.85; }
.cfr-parecer__sintese { margin: 0 0 12px; padding-left: 18px; font-size: 0.92rem; }
.cfr-parecer__sintese li { margin-bottom: 2px; }
.cfr-parecer__meta { color: var(--texto-muted); font-size: 0.8rem; display: flex; flex-wrap: wrap; gap: 4px 18px; }
.cfr-parecer__meta strong { color: var(--texto); font-weight: 600; }
/* ---- Cards de números ---- */
.cfr-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 28px; }
.cfr-card {
background: #fff;
border: 1px solid var(--borda);
border-radius: 14px;
padding: 14px 16px;
box-shadow: var(--sombra);
animation: cfrFadeSlideUp 360ms ease 60ms both;
}
.cfr-card__label { font-size: 0.7rem; color: var(--texto-muted); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; }
.cfr-card__valor { font-size: 1.2rem; font-weight: 800; color: var(--roxo-escuro); margin-top: 4px; font-variant-numeric: tabular-nums; }
.cfr-card__nota { font-size: 0.72rem; color: var(--texto-muted); margin-top: 2px; }
.cfr-barra { height: 6px; background: var(--neutro-bg); border-radius: 999px; overflow: hidden; margin-top: 8px; }
.cfr-barra span { display: block; height: 100%; background: linear-gradient(90deg, var(--roxo-medio), var(--roxo-escuro)); border-radius: 999px; }
/* ---- Seções ---- */
.cfr-secao { margin-bottom: 32px; }
.cfr-secao > h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
color: var(--roxo-escuro);
font-size: 1.1rem;
font-weight: 800;
}
.cfr-secao > h2 .cfr-num {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--roxo-escuro);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
}
.cfr-secao__desc { margin: 0 0 14px; color: var(--texto-muted); font-size: 0.84rem; }
.cfr-subtitulo { margin: 20px 0 8px; font-size: 0.92rem; font-weight: 700; color: var(--texto); }
.cfr-subtitulo small { color: var(--texto-muted); font-weight: 500; }
.cfr-vazio {
background: #fff;
border: 1px dashed var(--borda);
border-radius: 12px;
padding: 14px 16px;
color: var(--texto-muted);
font-size: 0.84rem;
}
/* ---- Blocos de risco ---- */
.cfr-risco {
background: #fff;
border: 1px solid var(--borda);
border-left: 4px solid var(--borda);
border-radius: 12px;
margin-bottom: 10px;
box-shadow: var(--sombra);
overflow: hidden;
}
.cfr-risco--alta { border-left-color: var(--falha-texto); }
.cfr-risco--media { border-left-color: var(--dourado); }
.cfr-risco--baixa { border-left-color: var(--roxo-medio); }
.cfr-risco > summary {
list-style: none;
cursor: pointer;
padding: 12px 16px;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 4px 12px;
align-items: center;
}
.cfr-risco > summary::-webkit-details-marker { display: none; }
.cfr-risco > summary:hover { background: #faf8fd; }
.cfr-risco__titulo { font-weight: 700; }
.cfr-risco__porque { grid-column: 2 / 4; color: var(--texto-muted); font-size: 0.8rem; }
.cfr-risco__resumo { text-align: right; font-size: 0.8rem; color: var(--texto-muted); white-space: nowrap; }
.cfr-risco__resumo strong { color: var(--texto); font-variant-numeric: tabular-nums; }
.cfr-risco__corpo { border-top: 1px solid var(--borda); padding: 12px 16px 16px; }
.cfr-risco__extra { font-size: 0.84rem; margin: 0 0 10px; }
.cfr-pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.cfr-pill--alta, .cfr-pill--divergente, .cfr-pill--pendente { background: var(--falha-bg); color: var(--falha-texto); }
.cfr-pill--media, .cfr-pill--atencao { background: var(--aviso-bg); color: var(--aviso-texto); }
.cfr-pill--baixa { background: var(--roxo-claro); color: var(--roxo-escuro); }
.cfr-pill--conciliada, .cfr-pill--ok { background: var(--sucesso-bg); color: var(--sucesso-texto); }
.cfr-pill--neutro { background: var(--neutro-bg); color: var(--texto-muted); }
/* ---- Tabelas ---- */
.cfr-tabela-wrap { background: #fff; border: 1px solid var(--borda); border-radius: 12px; overflow-x: auto; }
table.cfr-tabela { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
table.cfr-tabela th, table.cfr-tabela td { padding: 7px 10px; border-bottom: 1px solid var(--borda); text-align: left; vertical-align: top; }
table.cfr-tabela tr:last-child td { border-bottom: none; }
table.cfr-tabela th {
background: var(--roxo-claro);
color: var(--roxo-escuro);
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
table.cfr-tabela td.cfr-valor, table.cfr-tabela th.cfr-valor { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
table.cfr-tabela td.cfr-conta { white-space: nowrap; color: var(--texto-muted); }
table.cfr-tabela td.cfr-centro, table.cfr-tabela th.cfr-centro { text-align: center; }
.cfr-justificativa { font-size: 0.78rem; }
.cfr-justificativa em { color: var(--texto-muted); }
.cfr-sem-just { color: var(--falha-texto); font-weight: 600; font-size: 0.76rem; }
.cfr-lista-alertas { margin: 0; padding-left: 16px; }
.cfr-muted { color: var(--texto-muted); }
/* ---- Concentração ---- */
.cfr-conc-barra { display: flex; align-items: center; gap: 8px; min-width: 160px; }
.cfr-conc-barra .cfr-barra { flex: 1; margin: 0; }
.cfr-conc-barra .cfr-barra span { background: linear-gradient(90deg, var(--dourado-vivo), var(--dourado)); }
/* ---- Anexo ---- */
.cfr-anexo > summary { cursor: pointer; font-weight: 700; color: var(--roxo-escuro); margin-bottom: 10px; }
.cfr-footer { color: var(--texto-muted); font-size: 0.74rem; text-align: center; margin-top: 28px; }
.cfr-so-impressao { display: none; }
@media (max-width: 720px) {
.cfr-parecer { grid-template-columns: 1fr; }
.cfr-header { padding: 22px 20px; }
.cfr-risco > summary { grid-template-columns: auto 1fr; }
.cfr-risco__resumo { grid-column: 1 / 3; text-align: left; }
.cfr-risco__porque { grid-column: 1 / 3; }
}
/* Opção de impressão (só na tela): incluir ou não o anexo. */
.cfr-print-opcoes { display: flex; align-items: center; gap: 14px; margin-left: auto; }
.cfr-print-opcoes .cfr-print-btn { margin-left: 0; }
.cfr-print-check { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--dourado-claro); cursor: pointer; }
.cfr-print-check input { accent-color: var(--dourado-vivo); }
/* Impressão compacta: fundo branco, sem sombra nem gradiente, fonte e
espaçamento menores. Blocos grandes podem quebrar entre páginas (um
`break-inside: avoid` no bloco inteiro empurrava o bloco para a página
seguinte e deixava a anterior quase em branco); o que não quebra é a
linha de tabela, o cabeçalho de cada bloco (fica junto do conteúdo) e
os cards. O cabeçalho da tabela se repete em cada página. */
@page { size: A4; margin: 12mm 10mm; }
@media print {
* { animation: none !important; transition: none !important; box-shadow: none !important; }
html, body { background: #fff !important; font-size: 10px; line-height: 1.35; }
/* Fonte do sistema na impressão: as fontes da web embutidas no PDF
eram a maior parte do peso do arquivo. */
body, h1, h2, h3, .cfr-card__valor, .cfr-parecer__nivel { font-family: Arial, "Segoe UI", sans-serif; }
table.cfr-tabela th { white-space: normal; }
.cfr-nowrap { white-space: nowrap; }
.cfr-page { max-width: none; padding: 0; }
.cfr-print-opcoes { display: none; }
.cfr-header {
margin: 0 0 10px;
padding: 10px 14px;
background: var(--roxo-escuro);
border-bottom-width: 2px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cfr-header img { height: 32px; }
.cfr-header h1 { font-size: 1.15rem; }
.cfr-header p { font-size: 0.85rem; }
.cfr-aviso { padding: 6px 10px; margin-bottom: 10px; border-radius: 6px; }
.cfr-parecer, .cfr-parecer__risco, .cfr-pill, table.cfr-tabela th, .cfr-barra span, .cfr-aviso {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cfr-parecer { grid-template-columns: 150px 1fr; gap: 12px; padding: 10px; margin-bottom: 10px; border-radius: 8px; break-inside: avoid; }
.cfr-parecer__risco { padding: 10px; border-radius: 6px; }
.cfr-parecer__nivel { font-size: 1.4rem; }
.cfr-parecer__sintese { margin-bottom: 6px; font-size: 1rem; }
.cfr-cards { grid-template-columns: repeat(5, 1fr); gap: 6px; margin-bottom: 14px; }
.cfr-card { padding: 7px 9px; border-radius: 6px; break-inside: avoid; }
.cfr-card__valor { font-size: 1.1rem; }
.cfr-secao { margin-bottom: 14px; }
.cfr-secao > h2 { font-size: 1.25rem; margin-bottom: 2px; break-after: avoid; }
.cfr-secao > h2 .cfr-num { width: 18px; height: 18px; font-size: 0.8rem; }
.cfr-secao__desc { margin-bottom: 8px; }
.cfr-subtitulo { margin: 10px 0 4px; font-size: 1.05rem; break-after: avoid; }
.cfr-vazio { padding: 6px 10px; border-radius: 6px; }
.cfr-risco { border-radius: 6px; margin-bottom: 8px; overflow: visible; }
.cfr-risco > summary { padding: 6px 10px; break-inside: avoid; break-after: avoid; }
.cfr-risco__corpo { padding: 6px 10px 8px; }
.cfr-tabela-wrap { overflow: visible; border-radius: 6px; }
table.cfr-tabela { font-size: 0.95rem; }
table.cfr-tabela th, table.cfr-tabela td { padding: 3px 6px; }
table.cfr-tabela th { font-size: 0.8rem; }
table.cfr-tabela thead { display: table-header-group; }
table.cfr-tabela tr { break-inside: avoid; }
.cfr-anexo > summary { break-after: avoid; }
body:not(.cfr-imprime-anexo) .cfr-anexo-secao { display: none; }
.cfr-so-tela { display: none; }
.cfr-so-impressao { display: block; }
.cfr-footer { margin-top: 12px; }
}
</style>
</head>
<body>
<div class="cfr-page">
<header class="cfr-header">
<div class="cfr-header__brand">
{# cópia reduzida de logo-branco.png (6250 px de largura pesava mais que o PDF inteiro) #}
<img src="{% static 'img/logo-branco-relatorio.png' %}" alt="De Paula Contadores">
<div>
<h1>Conciliação de Fornecedores</h1>
<p>
{{ conciliacao.codigo_empresa }} · {{ conciliacao.nome_empresa|default:"Empresa não encontrada no Questor" }}
· Razão de {% if conciliacao.periodo_inicio %}{{ conciliacao.periodo_inicio|date:"d/m/Y" }} a {{ conciliacao.periodo_fim|date:"d/m/Y" }}{% else %}período sem lançamentos{% endif %}
· Data-base {{ conciliacao.data_base|date:"d/m/Y" }}
</p>
</div>
</div>
<div class="cfr-print-opcoes">
<label class="cfr-print-check">
<input type="checkbox" id="cfr-imprime-anexo">
Incluir anexo na impressão
</label>
<button type="button" class="cfr-print-btn" onclick="window.print()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
Imprimir
</button>
</div>
</header>
{% if not relatorio.concluida %}
<div class="cfr-aviso" role="note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>Conciliação em andamento: {{ relatorio.contas_nao_validadas|length }} de {{ relatorio.total_contas }} contas ainda sem validação. Este relatório mostra a situação no momento em que foi gerado.</span>
</div>
{% endif %}
<section class="cfr-parecer" aria-label="Parecer geral">
<div class="cfr-parecer__risco cfr-parecer__risco--{{ relatorio.risco_geral }}">
<span class="cfr-parecer__rotulo">Risco geral</span>
<span class="cfr-parecer__nivel">{{ relatorio.risco_geral_rotulo }}</span>
<span class="cfr-parecer__nota">Considera só os pontos ainda sem justificativa.</span>
</div>
<div>
<ul class="cfr-parecer__sintese">
{% for frase in relatorio.sintese %}<li>{{ frase }}.</li>{% endfor %}
</ul>
<div class="cfr-parecer__meta">
<span>Processado por <strong>{{ conciliacao.criado_por.nome|default:"—" }}</strong> em {{ conciliacao.criado_em|date:"d/m/Y H:i" }}</span>
{% with ultima=conciliacao.atualizacoes.first %}{% if ultima %}
<span>Razão atualizado por <strong>{{ ultima.usuario.nome|default:"—" }}</strong> em {{ ultima.criado_em|date:"d/m/Y H:i" }}</span>
{% endif %}{% endwith %}
<span>Arquivo <strong>{{ conciliacao.nome_arquivo|default:"—" }}</strong></span>
<span>Participaram <strong>{{ relatorio.responsaveis|join:", "|default:"—" }}</strong></span>
</div>
</div>
</section>
<div class="cfr-cards">
<div class="cfr-card">
<div class="cfr-card__label">Saldo de fornecedores</div>
<div class="cfr-card__valor">{{ relatorio.saldo_total|moeda }}</div>
<div class="cfr-card__nota">{{ relatorio.total_contas }} contas no razão</div>
</div>
<div class="cfr-card">
<div class="cfr-card__label">Títulos em aberto</div>
<div class="cfr-card__valor">{{ relatorio.total_em_aberto|moeda }}</div>
{% if relatorio.total_nao_vinculado %}<div class="cfr-card__nota">{{ relatorio.total_nao_vinculado|moeda }} em pagamentos a vincular</div>{% endif %}
</div>
<div class="cfr-card">
<div class="cfr-card__label">Há mais de {{ meses_vencido }} meses</div>
<div class="cfr-card__valor">{{ relatorio.total_vencido|moeda }}</div>
<div class="cfr-card__nota">{{ relatorio.vencidos_revisados|length }} de {{ relatorio.qtd_fornecedores_vencidos }} fornecedores revisados</div>
</div>
<div class="cfr-card">
<div class="cfr-card__label">Pagamentos sem título</div>
<div class="cfr-card__valor">{{ relatorio.total_excedente|moeda }}</div>
<div class="cfr-card__nota">além de todos os títulos em aberto</div>
</div>
<div class="cfr-card">
<div class="cfr-card__label">Validação</div>
<div class="cfr-card__valor">{{ relatorio.contas_validadas }} de {{ relatorio.total_contas }}</div>
<div class="cfr-barra" aria-hidden="true"><span style="width: {{ relatorio.percentual_validado }}%"></span></div>
<div class="cfr-card__nota">{{ relatorio.por_status.divergente }} divergentes · {{ relatorio.por_status.atencao }} em atenção · {{ relatorio.por_status.conciliada }} conciliadas</div>
</div>
</div>
<!-- 1. Situações de risco -->
<section class="cfr-secao">
<h2><span class="cfr-num">1</span> Situações de risco</h2>
<p class="cfr-secao__desc">Cada ponto traz o valor envolvido e por que merece atenção. Quando a conta foi validada com observação, a justificativa aparece ao lado.</p>
{% for risco in relatorio.riscos %}
<details class="cfr-risco cfr-risco--{{ risco.severidade }}"{% if risco.severidade == "alta" and risco.qtd_sem_justificativa %} open{% endif %}>
<summary>
<span class="cfr-pill cfr-pill--{{ risco.severidade }}">{{ risco.severidade_rotulo }}</span>
<span class="cfr-risco__titulo">{{ risco.titulo }}</span>
<span class="cfr-risco__resumo">
{{ risco.itens|length }} {% if risco.itens|length == 1 %}item{% else %}itens{% endif %}
{% if risco.valor_total %}· <strong>{{ risco.valor_total|moeda }}</strong>{% endif %}
{% if not risco.informativo %}· {% if risco.qtd_sem_justificativa %}{{ risco.qtd_sem_justificativa }} sem justificativa{% else %}todos justificados{% endif %}{% endif %}
</span>
<span class="cfr-risco__porque">{{ risco.porque }}{% if risco.informativo %} Informativo: não entra no risco geral.{% endif %}</span>
</summary>
<div class="cfr-risco__corpo">
{% if risco.chave == "juros_descontos" %}
<p class="cfr-risco__extra">Total pago a maior (possível juros/multa): <strong>{{ relatorio.total_juros|moeda }}</strong> · Total pago a menor (possível desconto): <strong>{{ relatorio.total_descontos|moeda }}</strong></p>
{% endif %}
{% if risco.chave == "vencidos" and relatorio.faixas_vencidos %}
<div class="cfr-tabela-wrap" style="margin-bottom: 12px;">
<table class="cfr-tabela">
<thead><tr><th>Faixa</th><th class="cfr-valor">Títulos</th><th class="cfr-valor">Fornecedores</th><th class="cfr-valor">Sem revisão</th><th class="cfr-valor">Valor</th></tr></thead>
<tbody>
{% for faixa in relatorio.faixas_vencidos %}
<tr>
<td>{{ faixa.rotulo }}</td>
<td class="cfr-valor">{{ faixa.qtd_titulos }}</td>
<td class="cfr-valor">{{ faixa.qtd_fornecedores }}</td>
<td class="cfr-valor">{% if faixa.qtd_sem_revisao %}<span class="cfr-sem-just">{{ faixa.qtd_sem_revisao }}</span>{% else %}0{% endif %}</td>
<td class="cfr-valor">{{ faixa.valor|moeda }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="cfr-tabela-wrap">
<table class="cfr-tabela">
<thead>
<tr>
<th>Conta</th><th>Fornecedor</th><th>Situação</th><th class="cfr-valor">Valor</th>
{% if not risco.informativo %}<th>Justificativa</th>{% endif %}
</tr>
</thead>
<tbody>
{% for item in risco.itens %}
<tr>
<td class="cfr-conta">{{ item.conta_numero }}</td>
<td>{{ item.fornecedor }}</td>
<td>{{ item.detalhe }}</td>
<td class="cfr-valor">{% if item.valor is not None %}{{ item.valor|moeda }}{% else %}—{% endif %}</td>
{% if not risco.informativo %}
<td class="cfr-justificativa">{% if item.justificado %}{{ item.justificativa|linebreaksbr }}{% else %}<span class="cfr-sem-just">Sem justificativa</span>{% endif %}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</details>
{% empty %}
<p class="cfr-vazio">Nenhuma situação de risco encontrada nesta conciliação.</p>
{% endfor %}
{% if relatorio.concentracao %}
<h3 class="cfr-subtitulo">Concentração do saldo em aberto <small>· os {{ relatorio.concentracao|length }} maiores fornecedores, onde um erro pesa mais</small></h3>
<div class="cfr-tabela-wrap">
<table class="cfr-tabela">
<thead><tr><th>Conta</th><th>Fornecedor</th><th class="cfr-valor">Em aberto</th><th>Participação</th></tr></thead>
<tbody>
{% for item in relatorio.concentracao %}
<tr>
<td class="cfr-conta">{{ item.conta_numero }}</td>
<td>{{ item.fornecedor }}</td>
<td class="cfr-valor">{{ item.valor|moeda }}</td>
<td><div class="cfr-conc-barra"><div class="cfr-barra"><span style="width: {% widthratio item.valor relatorio.total_em_aberto 100 %}%"></span></div><span>{{ item.participacao|percentual }}</span></div></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
<!-- 2. Decisões tomadas -->
<section class="cfr-secao">
<h2><span class="cfr-num">2</span> Decisões tomadas</h2>
<p class="cfr-secao__desc">O que foi decidido na conciliação, por quem e com qual justificativa.</p>
<h3 class="cfr-subtitulo">Contas validadas com pendência <small>· conta em Atenção ou Divergente validada por uma pessoa</small></h3>
{% if relatorio.validacoes_com_pendencia %}
<div class="cfr-tabela-wrap">
<table class="cfr-tabela">
<thead><tr><th>Conta</th><th>Fornecedor</th><th>Situação</th><th>Pendências</th><th>Validada por</th><th>Justificativa</th></tr></thead>
<tbody>
{% for v in relatorio.validacoes_com_pendencia %}
<tr>
<td class="cfr-conta">{{ v.conta_numero }}</td>
<td>{{ v.fornecedor }}</td>
<td><span class="cfr-pill cfr-pill--{{ v.status }}">{{ v.status_rotulo }}</span></td>
<td><ul class="cfr-lista-alertas">{% for a in v.alertas %}<li>{{ a }}</li>{% endfor %}</ul></td>
<td>{{ v.validada_por }}<br><span class="cfr-muted cfr-nowrap">{{ v.validada_em|date:"d/m/y H:i" }}</span></td>
<td class="cfr-justificativa">{% if v.observacao %}{{ v.observacao|linebreaksbr }}{% else %}<span class="cfr-sem-just">Sem justificativa</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cfr-vazio">Nenhuma conta com pendência foi validada.</p>
{% endif %}
<h3 class="cfr-subtitulo">Ajustes manuais nos vínculos <small>· pelo histórico de vinculações de cada conta</small></h3>
{% if relatorio.ajustes %}
<div class="cfr-tabela-wrap">
<table class="cfr-tabela">
<thead><tr><th>Conta</th><th style="width: 34%">Fornecedor</th><th class="cfr-centro">Criados</th><th class="cfr-centro">Acrésc.</th><th class="cfr-centro">Autom. desfeitos</th><th class="cfr-centro">Manuais desfeitos</th><th>Responsáveis</th><th>Última ação</th></tr></thead>
<tbody>
{% for a in relatorio.ajustes %}
<tr>
<td class="cfr-conta">{{ a.conta_numero }}</td>
<td>{{ a.fornecedor }}</td>
<td class="cfr-centro">{{ a.vinculou }}</td>
<td class="cfr-centro">{{ a.acrescentou }}</td>
<td class="cfr-centro">{% if a.desfez_automatico %}<strong>{{ a.desfez_automatico }}</strong>{% else %}0{% endif %}</td>
<td class="cfr-centro">{{ a.desfez_manual }}</td>
<td>{{ a.usuarios|join:", "|default:"—" }}</td>
<td class="cfr-muted cfr-nowrap">{{ a.ultima_acao|date:"d/m/y H:i" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cfr-vazio">Nenhum ajuste manual: todos os vínculos são os automáticos da importação.</p>
{% endif %}
{% if relatorio.saldos_parciais %}
<h3 class="cfr-subtitulo">Pagamentos parciais com saldo a pagar <small>· vínculos manuais em que o título não foi quitado por inteiro</small></h3>
<div class="cfr-tabela-wrap">
<table class="cfr-tabela">
<thead><tr><th>Conta</th><th>Fornecedor</th><th>Título de</th><th class="cfr-valor">Saldo a pagar</th><th>Vinculado por</th></tr></thead>
<tbody>
{% for s in relatorio.saldos_parciais %}
<tr>
<td class="cfr-conta">{{ s.conta_numero }}</td>
<td>{{ s.fornecedor }}</td>
<td>{{ s.data_titulo|date:"d/m/Y"|default:"—" }}</td>
<td class="cfr-valor">{{ s.valor|moeda }}</td>
<td>{{ s.criado_por|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<h3 class="cfr-subtitulo">Títulos antigos revisados <small>· aba "Em aberto há mais de {{ meses_vencido }} meses"</small></h3>
{% if relatorio.vencidos_revisados %}
<div class="cfr-tabela-wrap">
<table class="cfr-tabela">
<thead><tr><th>Conta</th><th>Fornecedor</th><th class="cfr-centro">Títulos</th><th>Mais antigo</th><th class="cfr-valor">Valor</th><th>Revisado por</th><th>Observação</th></tr></thead>
<tbody>
{% for v in relatorio.vencidos_revisados %}
<tr>
<td class="cfr-conta">{{ v.conta_numero }}</td>
<td>{{ v.fornecedor }}</td>
<td class="cfr-centro">{{ v.qtd_titulos }}</td>
<td>{{ v.mais_antigo|date:"d/m/Y" }}</td>
<td class="cfr-valor">{{ v.valor|moeda }}</td>
<td>{{ v.revisado_por|default:"—" }}<br><span class="cfr-muted cfr-nowrap">{{ v.revisado_em|date:"d/m/y H:i" }}</span></td>
<td class="cfr-justificativa">{% if v.observacao %}{{ v.observacao|linebreaksbr }}{% else %}<em>Sem observação</em>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cfr-vazio">Nenhum fornecedor com títulos antigos foi revisado ainda.</p>
{% endif %}
</section>
<!-- 3. O que ainda falta decidir -->
<section class="cfr-secao">
<h2><span class="cfr-num">3</span> O que ainda falta decidir</h2>
<p class="cfr-secao__desc">Contas e fornecedores sem nenhuma decisão registrada.</p>
<h3 class="cfr-subtitulo">Contas sem validação <small>· {{ relatorio.contas_nao_validadas|length }}</small></h3>
{% if relatorio.contas_nao_validadas %}
<div class="cfr-tabela-wrap">
<table class="cfr-tabela">
<thead><tr><th>Conta</th><th>Fornecedor</th><th>Situação</th><th>Motivo principal</th><th class="cfr-valor">Pagamentos pendentes</th></tr></thead>
<tbody>
{% for c in relatorio.contas_nao_validadas %}
<tr>
<td class="cfr-conta">{{ c.conta_numero }}</td>
<td>{{ c.fornecedor }}</td>
<td><span class="cfr-pill cfr-pill--{{ c.status }}">{{ c.status_rotulo }}</span></td>
<td>{{ c.motivo }}</td>
<td class="cfr-valor">{% if c.valor %}{{ c.valor|moeda }}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cfr-vazio">Todas as contas foram validadas.</p>
{% endif %}
<h3 class="cfr-subtitulo">Títulos antigos sem revisão <small>· {{ relatorio.vencidos_nao_revisados|length }} fornecedores</small></h3>
{% if relatorio.vencidos_nao_revisados %}
<p class="cfr-vazio cfr-so-impressao">Listados no item 1, "Títulos em aberto há mais de {{ meses_vencido }} meses", como "Sem justificativa".</p>
<div class="cfr-tabela-wrap cfr-so-tela">
<table class="cfr-tabela">
<thead><tr><th>Conta</th><th>Fornecedor</th><th class="cfr-centro">Títulos</th><th>Mais antigo</th><th class="cfr-valor">Valor</th></tr></thead>
<tbody>
{% for v in relatorio.vencidos_nao_revisados %}
<tr>
<td class="cfr-conta">{{ v.conta_numero }}</td>
<td>{{ v.fornecedor }}</td>
<td class="cfr-centro">{{ v.qtd_titulos }}</td>
<td>{{ v.mais_antigo|date:"d/m/Y" }}</td>
<td class="cfr-valor">{{ v.valor|moeda }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cfr-vazio">Todos os fornecedores com títulos antigos foram revisados.</p>
{% endif %}
</section>
<!-- 4. Anexo (fora da impressão, salvo se marcado no cabeçalho) -->
<section class="cfr-secao cfr-anexo-secao">
<details class="cfr-anexo">
<summary>Anexo: todos os fornecedores ({{ relatorio.anexo|length }})</summary>
<div class="cfr-tabela-wrap">
<table class="cfr-tabela">
<thead><tr><th>Conta</th><th>Fornecedor</th><th class="cfr-valor">Saldo inicial</th><th class="cfr-valor">Saldo final</th><th class="cfr-valor">Em aberto</th><th class="cfr-valor">Há mais de {{ meses_vencido }} meses</th><th>Situação</th><th>Validação</th></tr></thead>
<tbody>
{% for l in relatorio.anexo %}
<tr>
<td class="cfr-conta">{{ l.conta_numero }}</td>
<td>{{ l.fornecedor }}</td>
<td class="cfr-valor">{{ l.saldo_inicial|moeda }}</td>
<td class="cfr-valor">{{ l.saldo_final|moeda }}</td>
<td class="cfr-valor">{{ l.total_em_aberto|moeda }}</td>
<td class="cfr-valor">{% if l.total_vencido %}{{ l.total_vencido|moeda }}{% else %}—{% endif %}</td>
<td><span class="cfr-pill cfr-pill--{{ l.status }}">{{ l.status_rotulo }}</span></td>
<td>{% if l.validacao %}{{ l.validacao }}{% else %}<span class="cfr-pill cfr-pill--neutro">Pendente</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
</section>
<p class="cfr-footer">
Gerado em {{ gerado_em|date:"d/m/Y H:i" }} pelo Portal De Paula. Documento interno de análise do contador.
Os vínculos entre pagamentos e títulos são feitos por valor e data; possível duplicidade considera pagamentos de mesmo valor com até {{ janela_duplicidade }} dias entre eles.
</p>
</div>
<script>
document.getElementById("cfr-imprime-anexo").addEventListener("change", (e) => {
document.body.classList.toggle("cfr-imprime-anexo", e.target.checked);
});
// Impressão mostra todos os blocos abertos (o anexo só se marcado).
window.addEventListener("beforeprint", () => {
document.querySelectorAll("details").forEach((d) => {
d.dataset.cfrAberto = d.open ? "1" : "";
d.open = true;
});
});
window.addEventListener("afterprint", () => {
document.querySelectorAll("details").forEach((d) => {
d.open = d.dataset.cfrAberto === "1";
});
});
</script>
</body>
</html>

View File

@ -348,6 +348,11 @@
<input type="text" id="conc-form-codigo" inputmode="numeric" autocomplete="off" placeholder="Ex.: 2017" />
<p class="conc-form__empresa" id="conc-form-empresa"></p>
</div>
<div class="modal-field">
<label for="conc-form-periodo-fim">Razão até</label>
<input type="date" id="conc-form-periodo-fim" />
<p class="conc-form__dica">Data final da exportação do Questor. O início vem do arquivo.</p>
</div>
</div>
<div class="modal-field">
<label>Razão de fornecedores (.xlsx ou .csv)</label>
@ -361,6 +366,10 @@
</div>
</div>
<p class="modal-error" id="conc-form-erro"></p>
<div class="conc-progress" id="conc-form-progress" hidden>
<div class="conc-progress__bar"><div class="conc-progress__fill"></div></div>
<p class="conc-progress__label">Lendo o razão e conciliando os fornecedores, aguarde...</p>
</div>
<div class="modal-actions">
<button type="button" class="btn-outline" id="conc-form-cancelar-btn">Cancelar</button>
<button type="button" class="btn-solid" id="conc-form-processar-btn">Processar</button>
@ -407,10 +416,20 @@
<h2 class="conc-empresa__nome" id="conc-det-nome"></h2>
<div class="conc-empresa__meta" id="conc-det-meta"></div>
</div>
<div class="conc-empresa__acoes">
<button type="button" class="btn-outline" id="conc-det-atualizar-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Atualizar razão
</button>
<a class="btn-outline" id="conc-exportar-btn" href="#">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 4v12M12 16l4-4M12 16l-4-4" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" stroke-linecap="round"/></svg>
Exportar XLSX
</a>
<a class="btn-solid" id="conc-relatorio-btn" href="#" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="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"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>
Gerar relatório
</a>
</div>
</div>
<div class="conc-cards" id="conc-cards"></div>
@ -564,6 +583,48 @@
</div>
</div>
<!-- Atualizar o razão de uma conciliação (arquivo novo substitui o anterior) -->
<div class="modal-overlay" id="conc-atualizar-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Atualizar razão</h2>
<p class="modal-card__subtitle" id="conc-atualizar-empresa"></p>
<div class="conc-atualizar-info">
<p>O arquivo novo <strong>substitui</strong> o razão desta conciliação, e a análise é refeita sobre o período dele.</p>
<ul>
<li>O que ficou antes do início do arquivo novo passa a ser só o saldo anterior de cada fornecedor.</li>
<li>Vínculos, validações e observações são mantidos quando os lançamentos envolvidos continuam no arquivo novo; vínculo com lançamento que saiu do razão é desfeito.</li>
<li>Conta que recebeu ou perdeu lançamentos volta a "a validar" (a observação fica).</li>
</ul>
<p class="conc-atualizar-info__atual" id="conc-atualizar-periodo"></p>
</div>
<div class="modal-field">
<label>Razão atualizado (.xlsx ou .csv)</label>
<div class="conc-file-field">
<label class="btn-outline conc-file-field__btn" for="conc-atualizar-arquivo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Selecionar arquivo
</label>
<input type="file" id="conc-atualizar-arquivo" accept=".xlsx,.csv" class="conc-file-field__input" />
<span class="conc-file-field__name" id="conc-atualizar-arquivo-nome">Nenhum arquivo selecionado</span>
</div>
</div>
<div class="modal-field">
<label for="conc-atualizar-periodo-fim">Razão até</label>
<input type="date" id="conc-atualizar-periodo-fim" />
<p class="conc-form__dica">Data final da exportação do Questor, mesmo sem lançamento nos últimos dias. O início vem do arquivo.</p>
</div>
<p class="modal-error" id="conc-atualizar-erro"></p>
<div class="conc-progress" id="conc-atualizar-progress" hidden>
<div class="conc-progress__bar"><div class="conc-progress__fill"></div></div>
<p class="conc-progress__label">Lendo o razão novo e refazendo a análise, aguarde...</p>
</div>
<div class="modal-actions">
<button type="button" class="btn-outline" id="conc-atualizar-cancelar-btn">Cancelar</button>
<button type="button" class="btn-solid" id="conc-atualizar-processar-btn">Atualizar</button>
</div>
</div>
</div>
<div class="modal-overlay" id="password-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Alterar senha</h2>

View File

@ -460,6 +460,10 @@
</div>
<div class="dc-achados-list" id="dc-achados-list"></div>
<p class="dc-empty" id="dc-achados-empty" hidden>Nenhuma observação para este filtro.</p>
<button type="button" class="dc-av-aviso" id="dc-av-aviso" hidden>
<span class="dc-av-aviso__texto" id="dc-av-aviso-texto"></span>
<span class="dc-av-aviso__link">Ver na Análise Vertical &rarr;</span>
</button>
</div>
<div class="pa-tab-panel" data-dc-panel="balancete" hidden>
@ -564,7 +568,7 @@
<div class="dc-dash-obs-list" id="dc-av-obs-list"></div>
<p class="dc-empty" id="dc-av-obs-empty" hidden>Nenhuma observação registrada na Análise Vertical ainda.</p>
</section>
<section class="dc-obs-resumo dc-variacoes">
<section class="dc-obs-resumo dc-variacoes" id="dc-av-variacoes">
<h3 class="dc-obs-resumo__titulo">Variação atípica na DRE - Análise Vertical <span class="dc-obs-resumo__count" id="dc-av-variacoes-count">0</span></h3>
<p class="dc-variacoes__dica">Confira cada variação e clique em "Validar". A observação escrita aqui é a mesma da linha na tabela: fica no histórico da empresa e pode ser mostrada ao cliente.</p>
<div class="dc-variacoes__lista" id="dc-av-variacoes-list"></div>