Melhorias na Conciliação Fornecedores e Relatório Contábil
This commit is contained in:
parent
624f69dafd
commit
8218d58000
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@ -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`.
|
||||
|
||||
|
||||
@ -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."**:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`.
|
||||
|
||||
70
portal_api/conciliacao_fornecedores/atualizacao.py
Normal file
70
portal_api/conciliacao_fornecedores/atualizacao.py
Normal 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}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
702
portal_api/conciliacao_fornecedores/relatorio.py
Normal file
702
portal_api/conciliacao_fornecedores/relatorio.py
Normal 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,
|
||||
)
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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})"
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
BIN
static/img/logo-branco-relatorio.png
Normal file
BIN
static/img/logo-branco-relatorio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
718
templates/conciliacao-fornecedores-relatorio.html
Normal file
718
templates/conciliacao-fornecedores-relatorio.html
Normal 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>
|
||||
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@ -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 →</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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user