Nova aplicação - Conciliação de Fornecedores
This commit is contained in:
parent
df1f4e6a1f
commit
0287de814e
@ -151,7 +151,9 @@
|
|||||||
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista --dry-run -v 2)",
|
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista --dry-run -v 2)",
|
||||||
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista)",
|
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista)",
|
||||||
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\33b72ffc-a7b3-4b7e-8afe-bb65121e9a85\\\\scratchpad\\\\testa_removidos.py\")",
|
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\33b72ffc-a7b3-4b7e-8afe-bb65121e9a85\\\\scratchpad\\\\testa_removidos.py\")",
|
||||||
"PowerShell(.venv\\\\Scripts\\\\python.exe manage.py migrate portal_api 0080)"
|
"PowerShell(.venv\\\\Scripts\\\\python.exe manage.py migrate portal_api 0080)",
|
||||||
|
"Bash(file *)",
|
||||||
|
"Bash(iconv -f latin1 -t utf-8)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design",
|
"C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design",
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@ -27,6 +27,7 @@ Aplicações com pacote Python próprio (`CLAUDE.md` **carregado automaticamente
|
|||||||
| Simulação de Custo de Contratação | `portal_api/custo_contratacao/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
| Simulação de Custo de Contratação | `portal_api/custo_contratacao/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
||||||
| Não Conformidades | `portal_api/nao_conformidades/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
| Não Conformidades | `portal_api/nao_conformidades/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
||||||
| Relatório Contábil | `portal_api/dashboard_contabil/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
| Relatório Contábil | `portal_api/dashboard_contabil/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
||||||
|
| Conciliação de Fornecedores | `portal_api/conciliacao_fornecedores/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
||||||
|
|
||||||
Aplicações sem pacote Python dedicado (código ainda em `portal_api/models.py`/`views.py`/`serializers.py` — os arquivos em `docs/<app>/` **não** são carregados automaticamente, ler manualmente):
|
Aplicações sem pacote Python dedicado (código ainda em `portal_api/models.py`/`views.py`/`serializers.py` — os arquivos em `docs/<app>/` **não** são carregados automaticamente, ler manualmente):
|
||||||
|
|
||||||
@ -96,7 +97,7 @@ Ver `docs/identidade-visual/identidade-visual.md` para o inventário de cada arq
|
|||||||
|
|
||||||
### Backend serve o frontend (mesma origem)
|
### Backend serve o frontend (mesma origem)
|
||||||
|
|
||||||
`config/urls.py` registra `path("api/", include("portal_api.urls"))` e, para cada uma das **14 páginas HTML** do frontend (`index.html` mais os 13 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/`. 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.
|
||||||
|
|
||||||
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`).
|
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`).
|
||||||
|
|
||||||
@ -124,6 +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`. |
|
| `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`. |
|
| `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`. |
|
| `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). |
|
||||||
| `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`. |
|
| `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)
|
### API (sessão + CSRF, não token)
|
||||||
@ -179,6 +181,7 @@ Pacotes Python puros (sem ORM), um por ferramenta — cada um com seu próprio `
|
|||||||
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver `portal_api/custo_contratacao/CLAUDE.md`). |
|
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver `portal_api/custo_contratacao/CLAUDE.md`). |
|
||||||
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver `portal_api/indicadores/CLAUDE.md`). |
|
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver `portal_api/indicadores/CLAUDE.md`). |
|
||||||
| `nao-conformidades.html` | Aplicação de Relatórios > Qualidade: gestão contínua das ocorrências/ações do Sigsistem, com dashboard e status interno de tratativa da Qualidade (ver `portal_api/nao_conformidades/CLAUDE.md`). |
|
| `nao-conformidades.html` | Aplicação de Relatórios > Qualidade: gestão contínua das ocorrências/ações do Sigsistem, com dashboard e status interno de tratativa da Qualidade (ver `portal_api/nao_conformidades/CLAUDE.md`). |
|
||||||
|
| `conciliacao-fornecedores.html` | Ferramenta de Utilitários: histórico + nova conciliação (código da empresa com nome buscado no Questor + razão de fornecedores .xlsx/.csv) + detalhe com cards de alerta e tabela de fornecedores expansível, vínculo manual, validação e exportação XLSX (ver `portal_api/conciliacao_fornecedores/CLAUDE.md`). |
|
||||||
| `dashboard-contabil.html` | Aplicação de Relatórios > Contabilidade ("Relatório Contábil" na UI): histórico de análises + nova análise (upload do PDF de Balancete + DRE) + revisão de achados de auditoria (com observações por conta) + botão "Gerar Relatório" (relatório HTML autocontido, ver `portal_api/dashboard_contabil/CLAUDE.md`). |
|
| `dashboard-contabil.html` | Aplicação de Relatórios > Contabilidade ("Relatório Contábil" na UI): histórico de análises + nova análise (upload do PDF de Balancete + DRE) + revisão de achados de auditoria (com observações por conta) + botão "Gerar Relatório" (relatório HTML autocontido, ver `portal_api/dashboard_contabil/CLAUDE.md`). |
|
||||||
|
|
||||||
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
|
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
|
||||||
@ -338,6 +341,12 @@ Gestão contínua das ocorrências e ações do Sistema de Gestão da Qualidade
|
|||||||
|
|
||||||
Ver `portal_api/nao_conformidades/CLAUDE.md`.
|
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: títulos em aberto há mais de 2 meses, pagamentos sem título (divergência), diferenças de juros/desconto e conta transitória com saldo. Vínculo manual, validação por conta, histórico salvo e exportação XLSX. Restrita ao perfil Inovação.
|
||||||
|
|
||||||
|
Ver `portal_api/conciliacao_fornecedores/CLAUDE.md`.
|
||||||
|
|
||||||
## Relatório Contábil (Relatórios > Contabilidade)
|
## Relatório Contábil (Relatórios > Contabilidade)
|
||||||
|
|
||||||
Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado pra "Relatório Contábil" a pedido explícito do usuário, pra soar como um aliado do trabalho do contador em vez de mais um processo/sistema novo (rename só de rótulo visível: menu, título da página, cabeçalhos e o botão que gera o relatório; nomes técnicos internos — pasta do pacote, arquivos, classes de model, rotas — continuam `dashboard_contabil`/`dashboard-contabil.*`/`Contabil*`/`/contabil-*`, sem nenhuma mudança).
|
Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado pra "Relatório Contábil" a pedido explícito do usuário, pra soar como um aliado do trabalho do contador em vez de mais um processo/sistema novo (rename só de rótulo visível: menu, título da página, cabeçalhos e o botão que gera o relatório; nomes técnicos internos — pasta do pacote, arquivos, classes de model, rotas — continuam `dashboard_contabil`/`dashboard-contabil.*`/`Contabil*`/`/contabil-*`, sem nenhuma mudança).
|
||||||
@ -366,6 +375,7 @@ Ver `portal_api/dashboard_contabil/CLAUDE.md`.
|
|||||||
| `custo-contratacao.css` | `.cc-*` — só usado em `custo-contratacao.html`. |
|
| `custo-contratacao.css` | `.cc-*` — só usado em `custo-contratacao.html`. |
|
||||||
| `indicador-desempenho.css` | `.ind-*` — só usado em `indicador-desempenho.html`; carrega `perfis-acesso.css` também, pelo mesmo motivo de `importacao-plano-saude.css` (tabela/abas de revisão). |
|
| `indicador-desempenho.css` | `.ind-*` — só usado em `indicador-desempenho.html`; carrega `perfis-acesso.css` também, pelo mesmo motivo de `importacao-plano-saude.css` (tabela/abas de revisão). |
|
||||||
| `nao-conformidades.css` | `.ncf-*` — só usado em `nao-conformidades.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs`/`.status-pill` (modificadores `--ok`/`--danger`/`--warning`/`--neutral` novos, em cima do `.status-pill` já existente ali). |
|
| `nao-conformidades.css` | `.ncf-*` — só usado em `nao-conformidades.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs`/`.status-pill` (modificadores `--ok`/`--danger`/`--warning`/`--neutral` novos, em cima do `.status-pill` já existente ali). |
|
||||||
|
| `conciliacao-fornecedores.css` | `.conc-*` — só usado em `conciliacao-fornecedores.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-search`/`.status-pill`. |
|
||||||
| `dashboard-contabil.css` | `.dc-*` — só usado em `dashboard-contabil.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs` na revisão de achados/Balancete/DRE. |
|
| `dashboard-contabil.css` | `.dc-*` — só usado em `dashboard-contabil.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs` na revisão de achados/Balancete/DRE. |
|
||||||
|
|
||||||
Ao adicionar uma tela nova que precise de modal, reuse `.modal-overlay`/`.modal-card` de `components.css` e só crie estilos de campo próprios se o formulário não for um caso simples de texto/select (que já tem equivalente em `.pa-field` ou `.modal-field`).
|
Ao adicionar uma tela nova que precise de modal, reuse `.modal-overlay`/`.modal-card` de `components.css` e só crie estilos de campo próprios se o formulário não for um caso simples de texto/select (que já tem equivalente em `.pa-field` ou `.modal-field`).
|
||||||
|
|||||||
@ -39,6 +39,7 @@ Não há suíte de testes, lint ou build configurados neste projeto.
|
|||||||
| Perfis de Acesso / Usuários | Administração | [docs/perfis-usuarios/](docs/perfis-usuarios/README.md) |
|
| Perfis de Acesso / Usuários | Administração | [docs/perfis-usuarios/](docs/perfis-usuarios/README.md) |
|
||||||
| Importação de Plano de Saúde | Utilitários | [portal_api/planos_saude/](portal_api/planos_saude/README.md) |
|
| Importação de Plano de Saúde | Utilitários | [portal_api/planos_saude/](portal_api/planos_saude/README.md) |
|
||||||
| Importação de Plano de Saúde - De Paula (mesma ferramenta, para o plano dos próprios colaboradores) | Utilitários | [portal_api/planos_saude/](portal_api/planos_saude/README.md) |
|
| Importação de Plano de Saúde - De Paula (mesma ferramenta, para o plano dos próprios colaboradores) | Utilitários | [portal_api/planos_saude/](portal_api/planos_saude/README.md) |
|
||||||
|
| Conciliação de Fornecedores | Utilitários | [portal_api/conciliacao_fornecedores/](portal_api/conciliacao_fornecedores/README.md) |
|
||||||
| Simulação de Custo de Contratação | Geradoc | [portal_api/custo_contratacao/](portal_api/custo_contratacao/README.md) |
|
| Simulação de Custo de Contratação | Geradoc | [portal_api/custo_contratacao/](portal_api/custo_contratacao/README.md) |
|
||||||
| Indicador de Desempenho | Geradoc | [portal_api/indicadores/](portal_api/indicadores/README.md) |
|
| Indicador de Desempenho | Geradoc | [portal_api/indicadores/](portal_api/indicadores/README.md) |
|
||||||
| Não Conformidades | Relatórios > Qualidade | [portal_api/nao_conformidades/](portal_api/nao_conformidades/README.md) |
|
| Não Conformidades | Relatórios > Qualidade | [portal_api/nao_conformidades/](portal_api/nao_conformidades/README.md) |
|
||||||
|
|||||||
@ -23,6 +23,7 @@ PAGINAS_FRONTEND = [
|
|||||||
"ramais.html",
|
"ramais.html",
|
||||||
"importacao-plano-saude.html",
|
"importacao-plano-saude.html",
|
||||||
"importacao-plano-saude-de-paula.html",
|
"importacao-plano-saude-de-paula.html",
|
||||||
|
"conciliacao-fornecedores.html",
|
||||||
"custo-contratacao.html",
|
"custo-contratacao.html",
|
||||||
"indicador-desempenho.html",
|
"indicador-desempenho.html",
|
||||||
"nao-conformidades.html",
|
"nao-conformidades.html",
|
||||||
|
|||||||
Binary file not shown.
@ -6,6 +6,10 @@ from .models import (
|
|||||||
AcessoGeralSecao,
|
AcessoGeralSecao,
|
||||||
AjudaAplicacao,
|
AjudaAplicacao,
|
||||||
CompromissoAgenda,
|
CompromissoAgenda,
|
||||||
|
ConciliacaoFornecedor,
|
||||||
|
ConciliacaoFornecedorConta,
|
||||||
|
ConciliacaoFornecedorLancamento,
|
||||||
|
ConciliacaoFornecedorVinculo,
|
||||||
ContabilAchado,
|
ContabilAchado,
|
||||||
ContabilApuracao,
|
ContabilApuracao,
|
||||||
ContabilApuracaoReprocessamento,
|
ContabilApuracaoReprocessamento,
|
||||||
@ -308,3 +312,30 @@ class ContabilAchadoAdmin(admin.ModelAdmin):
|
|||||||
list_display = ("apuracao", "regra", "severidade", "status", "titulo")
|
list_display = ("apuracao", "regra", "severidade", "status", "titulo")
|
||||||
list_filter = ("regra", "severidade", "status")
|
list_filter = ("regra", "severidade", "status")
|
||||||
search_fields = ("titulo", "mensagem")
|
search_fields = ("titulo", "mensagem")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ConciliacaoFornecedor)
|
||||||
|
class ConciliacaoFornecedorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("codigo_empresa", "nome_empresa", "data_base", "criado_por", "criado_em")
|
||||||
|
search_fields = ("codigo_empresa", "nome_empresa")
|
||||||
|
ordering = ("-criado_em",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ConciliacaoFornecedorConta)
|
||||||
|
class ConciliacaoFornecedorContaAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("conciliacao", "conta_numero", "fornecedor", "saldo_inicial", "saldo_final", "validada")
|
||||||
|
list_filter = ("validada",)
|
||||||
|
search_fields = ("conta_numero", "fornecedor")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ConciliacaoFornecedorVinculo)
|
||||||
|
class ConciliacaoFornecedorVinculoAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("conta", "tipo", "diferenca", "criado_por", "criado_em")
|
||||||
|
list_filter = ("tipo",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ConciliacaoFornecedorLancamento)
|
||||||
|
class ConciliacaoFornecedorLancamentoAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("conta", "data", "natureza", "valor", "historico", "vinculo")
|
||||||
|
list_filter = ("natureza", "eh_saldo_anterior")
|
||||||
|
search_fields = ("historico",)
|
||||||
|
|||||||
@ -70,6 +70,9 @@ MODULE_APPS = {
|
|||||||
"utilitarios": [
|
"utilitarios": [
|
||||||
{"key": "importacao-plano-saude", "label": "Importação de Plano de Saúde"},
|
{"key": "importacao-plano-saude", "label": "Importação de Plano de Saúde"},
|
||||||
{"key": "importacao-plano-saude-de-paula", "label": "Importação de Plano de Saúde - De Paula"},
|
{"key": "importacao-plano-saude-de-paula", "label": "Importação de Plano de Saúde - De Paula"},
|
||||||
|
# Restrita ao perfil "Inovação" por decisão do usuário (override em
|
||||||
|
# seed_portal.py), liberada a outros perfis depois pela tela.
|
||||||
|
{"key": "conciliacao-fornecedores", "label": "Conciliação de Fornecedores"},
|
||||||
],
|
],
|
||||||
"integracoes": [
|
"integracoes": [
|
||||||
{"key": "questor", "label": "Questor"},
|
{"key": "questor", "label": "Questor"},
|
||||||
|
|||||||
104
portal_api/conciliacao_fornecedores/CHANGELOG.md
Normal file
104
portal_api/conciliacao_fornecedores/CHANGELOG.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# Changelog — Conciliação de Fornecedores
|
||||||
|
|
||||||
|
> Histórico específico desta aplicação.
|
||||||
|
>
|
||||||
|
> **Formato**: uma entrada por rodada, `### Rodada N — Título`. **A numeração de rodada não é global**: cada aplicação conta as próprias. Ao citar uma rodada, sempre nomear o arquivo. Ver o topo de `plano.md`.
|
||||||
|
|
||||||
|
### Rodada 1 — Primeira versão (2026-09-24)
|
||||||
|
|
||||||
|
Pedido: facilitar a conciliação contábil, começando pelos fornecedores. O contador exporta o razão do Questor (com saldo residual por fornecedor), informa o código da empresa (nome buscado no Questor, mesma consulta da Importação de Plano de Saúde) e a ferramenta valida débitos e créditos, vincula e devolve o que está pendente. Tela: empresa e cards de alerta no topo, tabela de fornecedores (conta, fornecedor, saldo inicial, saldo residual, validado/divergente) que expande mostrando só o que não foi conciliado.
|
||||||
|
|
||||||
|
Decisões confirmadas com o usuário antes de implementar: ferramenta dentro do Portal em Utilitários (não um portal separado); conciliação salva em histórico; ajuste manual (desfazer/criar vínculo, validar conta); tolerância com valor fixo e percentual; "mais de 2 meses a partir da saída"; conta "TRANSFERÊNCIA PARA CUSTOS E DESPESAS" é transitória e não deve ter saldo; adiantamento conta como pagamento; acesso só para o perfil Inovação; exportação do resultado. O usuário salvou a exportação em XLSX e CSV; os dois são aceitos, XLSX recomendado.
|
||||||
|
|
||||||
|
O que foi construído: pacote `portal_api/conciliacao_fornecedores/` (`parser`, `motor`, `alertas`, `exportacao`), 4 models (migração `0082`), 3 ViewSets, item no catálogo e no menu dos 13 shells, página nova.
|
||||||
|
|
||||||
|
Achado durante o teste com o arquivo real (empresa 2017, 260 contas): parcelamentos são comuns (NF 1.299,00 em 3 × 433,00), e a regra literal "débito sobrando = divergência" marcaria quase toda conta com saldo como divergente. Resolvido com a distribuição dos pagamentos pendentes sobre os títulos em aberto (parcela primeiro, senão o título mais antigo); só o que excede os títulos vira divergência, e pagamento sem título de mesmo valor vira atenção. Também a busca de combinações foi limitada aos 12 candidatos mais próximos em data, porque em contas com centenas de lançamentos pequenos surgiam somas coincidentes.
|
||||||
|
|
||||||
|
Resultado no arquivo real: 146 contas conciliadas, 38 pendentes de pagamento, 75 com atenção, 1 divergente (a conta transitória, R$ 20.707,58 devedor); saldo em aberto calculado bate com o saldo final de cada fornecedor. API testada de ponta a ponta dentro de transação desfeita; tela conferida por print em Chrome headless com a API simulada a partir desses dados (não com o servidor real).
|
||||||
|
|
||||||
|
Interpretações da implementação, a confirmar com o usuário: "vale o maior" entre tolerância fixa e percentual; "2 meses" contados da data do título até a data-base informada (default hoje).
|
||||||
|
|
||||||
|
### Rodada 2 — Data-base e tolerâncias internas (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário, ao ver o formulário: data-base e as duas tolerâncias "devem ficar internas no portal", sem alteração pela execução da aplicação. Os três campos saíram da tela; o serializer de criação só aceita `codigo_empresa` + `arquivo`, e o servidor usa a data do processamento e `motor.TOLERANCIA_VALOR_PADRAO`/`TOLERANCIA_PERCENTUAL_PADRAO` (R$ 10,00 / 2%), ignorando qualquer valor enviado (testado enviando outros valores). Os valores usados continuam gravados em cada conciliação e aparecem no cabeçalho do detalhe, só para consulta.
|
||||||
|
|
||||||
|
### Rodada 3 — Checkbox de validado na linha do fornecedor (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: um checkbox no final da linha de cada fornecedor para confirmar que a conta foi validada. Coluna "Validado" nova (ordenável) na tabela do detalhe; marcar/desmarcar chama `POST /conciliacoes-fornecedores-contas/{id}/validar/` só com `validada` (a observação existente é preservada) e atualiza a linha e os cards com a resposta. O clique no checkbox não expande a linha; o botão "Marcar como validada" do detalhe continua existindo junto da observação.
|
||||||
|
|
||||||
|
### Rodada 4 — Validação separada da situação; conciliadas já validadas (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: marcar como validada não deve mudar a situação do fornecedor, e toda conta já "Conciliada" deve vir validada. A situação exibida passou a ser sempre a calculada (o status "Validada" deixou de existir; `resumo.por_status` perdeu a chave `validada` e ganhou `resumo.validadas`). Conciliadas são validadas automaticamente na criação (`validada_por` nulo = automática); vincular/desfazer reaplica a regra (aplica se ficou conciliada, retira a automática se deixou de estar), sem tocar em validação manual. Chip novo "Não validados", filtro "Validados" pela marcação, coluna "Validada" no XLSX. Aplicado também na conciliação que já existia no banco (id 4, empresa 2017: 146 contas validadas).
|
||||||
|
|
||||||
|
### Rodada 5 — Hover só no lançamento sob o mouse (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: com um fornecedor aberto, passar o mouse sobre um lançamento clareava todos. Causa: as sub-tabelas de lançamentos ficam dentro da `.pa-table` de fornecedores, e tanto `.pa-table tr:hover td` (perfis-acesso.css) quanto `.conc-detail-row:hover td` (desta folha, com `!important`) casavam com toda célula descendente da linha de detalhe. A segunda virou `> td` (só a célula externa); a sub-tabela ganhou fundo neutro, hover na linha e seleção próprios, com especificidade maior que a regra de `perfis-acesso.css`. Sem conferência visual do hover neste ambiente (Chrome headless não simula mouse).
|
||||||
|
|
||||||
|
### Rodada 6 — Ordem padrão, 3 meses e tolerância fora da tela (2026-09-24)
|
||||||
|
|
||||||
|
Pedidos do usuário: (1) tabela de fornecedores ordenada por padrão pela situação, divergentes primeiro (`SORT_PADRAO = "status"` no JS, reaplicado a cada detalhe aberto; o rank de `PID_CONC_STATUS` já punha divergente em 0); (2) alerta de título antigo passa de 2 para 3 meses (`MESES_VENCIDO = 3`, textos de card/coluna/XLSX); (3) a tolerância saiu do cabeçalho do detalhe, aparecendo só como o alerta de diferença no fornecedor em que foi usada. Como aging e situação são recalculados a cada leitura, as conciliações já gravadas passaram a usar 3 meses sozinhas; só o `resumo` gravado foi recalculado (conciliação 8, empresa 2017: atenção 75 → 58, pendentes 38 → 55, em aberto > 3 meses R$ 196.360,20).
|
||||||
|
|
||||||
|
### Rodada 7 — Validar recolhe a linha (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: ao marcar a conta como validada, a expansão dela deve recolher. `aplicarRespostaConta()` recolhe a linha quando a conta passa de não validada para validada, qualquer que seja o caminho (checkbox da linha, botão "Marcar como validada" ou validação automática depois de um vínculo que deixou a conta conciliada). Remover a validação não mexe na expansão.
|
||||||
|
|
||||||
|
### Rodada 8 — Tela do detalhe mais leve (2026-09-24)
|
||||||
|
|
||||||
|
O usuário achou a página carregada. Sugestões apresentadas antes de executar, conferidas num protótipo HTML com os dados reais (`Projetos\Conciliação\prototipo-visualizacao.html`, fora do repositório) e aprovadas:
|
||||||
|
- (A) saíram os 4 cards de situação, que repetiam os chips;
|
||||||
|
- (B) os cards de valor viraram uma faixa com 3 indicadores e a barra de progresso da validação;
|
||||||
|
- (C) os apontamentos menores viraram etiquetas;
|
||||||
|
- (D) as validadas ficam escondidas por padrão, com "Mostrar validadas" (o chip "Não validados" saiu, redundante com esse padrão);
|
||||||
|
- (E) as linhas ficaram compactas, com reticências e a situação sem ícone (`PID_CONC_ICONES` removido);
|
||||||
|
- (F) o cabeçalho da empresa ficou mais baixo;
|
||||||
|
- (G) os filtros e o thead ficam fixos ao rolar. Posições conferidas no Chrome headless depois de rolar: topbar 0-64px, filtros 64-116px, thead 116-156px.
|
||||||
|
|
||||||
|
### Rodada 9 — Cabeçalho da sub-tabela flutuando no meio dos lançamentos (2026-09-24)
|
||||||
|
|
||||||
|
Bug da rodada 8, reportado com print: o `position: sticky` era aplicado por `.conc-table thead th`, seletor descendente que também casava com o thead das sub-tabelas de lançamentos (que ficam dentro da tabela de fornecedores). O thead delas grudava a 116px do topo e aparecia no meio da lista. Trocado por `.conc-table > thead > tr > th`. Conferido medindo no Chrome headless com a conta 8582 expandida e a página rolada: o thead da sub-tabela acompanha a própria tabela, e só o da tabela de fornecedores fica fixo.
|
||||||
|
|
||||||
|
### Rodada 10 — Botão "Recolher" no fim da expansão (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: um botão para recolher o fornecedor expandido. Fica à direita da linha de ações da observação (fim da área expandida, onde o contador termina a análise); ao clicar, recolhe e, se a linha do fornecedor ficou acima da área visível (expansão longa), rola a tela de volta até ela.
|
||||||
|
|
||||||
|
### Rodada 11 — Distribuição como sugestão e vínculo de pagamento parcial (2026-09-24)
|
||||||
|
|
||||||
|
O usuário perguntou de onde vinham as "parcelas". Resposta: de nenhum lugar além do razão; é dedução por valor/data (`_distribui`), e no caso da ROZINI um pagamento virou "parcela" e o outro não só por centavos de arredondamento. Pedido: a ferramenta deve **sugerir**, não apresentar como verdade; se o contador quiser, ele vincula pela ferramenta.
|
||||||
|
|
||||||
|
Mudanças:
|
||||||
|
- toda situação derivada da distribuição tem `sugestao=True`, com texto de sugestão, etiqueta "Sugestão" (itálico), valor em aberto "≈" e prefixo no XLSX; os alertas de vencido e de pagamento sem título avisam quando o valor é estimado;
|
||||||
|
- achado no caminho: vincular à mão uma NF com pagamentos parciais fechava a NF e chamava a diferença de "desconto". Tipo novo `parcial` (migração `0083`, só choices): créditos maiores que débitos além da tolerância. O saldo continua em aberto e aparece num bloco "Pagamentos parciais vinculados", junto dos não conciliados, com "Desfazer". A tela avisa antes de vincular.
|
||||||
|
|
||||||
|
Testado com a ROZINI (NF 8.654,22 + 2.163,57 + 2.163,55): vira pagamento parcial com R$ 4.327,10 em aberto (vencido, NF de 28/05); some o alerta de pagamento sem título, e o saldo em aberto da conta segue igual ao saldo final.
|
||||||
|
|
||||||
|
### Rodada 12 — Valor em aberto sempre real, sugestão só em texto (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário, ao ver a NF de 8.654,22 com "≈ R$ 4.327,10" em aberto: enquanto nada for vinculado, o saldo em aberto deve ser o real, sem abater suposições. A distribuição deixou de alterar qualquer valor: título sem vínculo fica em aberto pelo valor cheio (inclusive no aging e no alerta de mais de 3 meses), pagamento sem vínculo é "Pagamento não vinculado" (categorias `parcela`/`sem_titulo`/`quitado_parcial` foram trocadas por `nao_vinculado`), e a dedução vai para `SituacaoItem.sugestao` (texto), exibida numa linha à parte. O excedente (divergência) continua, por ser fato. O card do topo virou "Títulos em aberto", com o valor em pagamentos a vincular ao lado (`total_nao_vinculado`, novo no payload e no resumo). Resumo da conciliação 8 recalculado: títulos em aberto R$ 942.722,79, mais de 3 meses R$ 514.720,32, pagamentos a vincular R$ 327.333,89; atenção 62, pendentes 51.
|
||||||
|
|
||||||
|
### Rodada 13 — Barra de seleção fixa ao rolar (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: com muitos lançamentos, a linha com a soma da seleção e o botão "Vincular selecionados" devem ficar fixos junto do cabeçalho, para acompanhar o cálculo e vincular sem subir a página. A barra (`.conc-selecao-barra`) e o thead da lista de pendentes ficam `sticky`, empilhados abaixo do thead da tabela de fornecedores, dentro da célula de detalhe (somem quando a conta sai da tela). Posições medidas no Chrome headless com a BRASPRESS (72 pendentes) rolada até o meio: topbar 0-64, filtros 64-116, thead 116-157, barra 157-201, thead dos pendentes 201-233.
|
||||||
|
|
||||||
|
### Rodada 14 — Clique na linha seleciona o lançamento (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: marcar a seleção clicando na linha do lançamento, não só no checkbox. A linha inteira da lista de pendentes alterna o checkbox (que fica como indicador); o clique no próprio checkbox segue o nativo, e um clique que termina uma seleção de texto (arrastar sobre o histórico para copiar) é ignorado. Só o histórico continua selecionável como texto.
|
||||||
|
|
||||||
|
### Rodada 15 — Marcar/desmarcar todos no cabeçalho dos pendentes (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: um controle no cabeçalho da lista de pendentes para marcar todos ou desmarcar os selecionados. Checkbox com três estados (vazio, parcial com `indeterminate`, todos): sem nada marcado, marca todos; com qualquer marcação, desmarca todos (padrão de caixa de e-mail, para "desmarcar os selecionados" funcionar também numa seleção parcial).
|
||||||
|
|
||||||
|
### Rodada 16 — Acrescentar lançamentos a um vínculo manual (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: depois de vincular um débito a um crédito, poder incluir outro débito no mesmo vínculo sem desfazê-lo. Endpoint novo `POST /conciliacoes-fornecedores-vinculos/{id}/adicionar/` (só vínculo manual/parcial) e botão "Adicionar selecionados" no cabeçalho de cada vínculo manual, habilitado quando há lançamentos marcados na lista de pendentes, com confirmação mostrando o efeito (quanto resta, se fecha, se fica pago a maior). A regra de tipo virou `_conciliacao_tipo_vinculo_manual()`, compartilhada com a criação. O serializer passou a aceitar 1 lançamento (a criação continua exigindo débito + crédito na view). Testado pela API com a ROZINI: NF + 1 pagamento = parcial restando R$ 6.490,65; acrescentado o 2º = R$ 4.327,10. Vínculo automático e lançamento já vinculado recusados. A tela só foi conferida quanto a erros de JS, porque a página de teste não tem vínculo manual nos dados.
|
||||||
|
|
||||||
|
### Rodada 17 — Razão desde a primeira pendência na conta expandida (2026-09-24)
|
||||||
|
|
||||||
|
Pedido do usuário: ver todo o período a partir de quando surgiu a pendência, com o que a aplicação já vinculou marcado como "Vinculado", para achar vinculações incorretas, desfazer e vincular de novo. A lista da conta expandida passou a ser o razão a partir da data do lançamento pendente mais antigo (inclusive itens de pagamento parcial), na ordem do arquivo, com a coluna "Saldo" (`saldo_apos`, novo no payload). Linhas vinculadas: sem checkbox, apagadas, "Vinculado #N" (número do vínculo na conta, o mesmo mostrado nos blocos de vínculo) + tipo e origem, destaque do vínculo inteiro no hover e "Desfazer" na linha (confirmação lista os itens do vínculo). Seleção, marcar todos e clique na linha continuam valendo só para as não vinculadas. Conferido com a ROZINI: saldos batem com o razão do Questor enviado pelo usuário.
|
||||||
|
|
||||||
|
### Rodada 18 — Colunas Débito e Crédito (testada e revertida, 2026-09-24)
|
||||||
|
|
||||||
|
A pedido do usuário, "Natureza" + "Valor" viraram colunas "Débito" + "Crédito" no razão da conta expandida e nos blocos de vínculo. Depois de ver na tela, **o usuário preferiu como estava e pediu para reverter**: voltaram "Natureza" (etiqueta) + "Valor". Não reaplicar colunas separadas de débito/crédito sem confirmar de novo.
|
||||||
|
|
||||||
|
### Rodada 19 — Toggle "Ocultar conciliados" no razão da conta expandida (2026-09-25)
|
||||||
|
|
||||||
|
Pedido do usuário: um botão para esconder o que já está conciliado no razão desde a primeira pendência, **desmarcado por padrão**. Checkbox "Ocultar conciliados" (estilo `.conc-toggle`, o mesmo de "Mostrar validadas") ao lado do título "Razão desde ... · N não conciliado(s)", com a contagem de linhas vinculadas no período; só aparece quando há alguma. Esconde as `tr.conc-linha--vinculada` pela classe `is-ocultando-conciliados` na tabela; o estado fica por conta (`ocultarConciliados` em `estadoDe`) e é mantido depois de vincular/desfazer. Só frontend (JS/CSS), sem mudança de API.
|
||||||
104
portal_api/conciliacao_fornecedores/CLAUDE.md
Normal file
104
portal_api/conciliacao_fornecedores/CLAUDE.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# Conciliação de Fornecedores (Utilitários)
|
||||||
|
|
||||||
|
> Este arquivo é carregado automaticamente ao trabalhar dentro de `portal_api/conciliacao_fornecedores/`. Ver `CLAUDE.md` na raiz para a arquitetura geral/transversal do Portal. Descreve o **estado atual**; o histórico rodada a rodada está em `CHANGELOG.md` nesta pasta. Ao implementar algo novo aqui, atualizar os dois.
|
||||||
|
|
||||||
|
O contador exporta do Questor o razão contábil das contas de fornecedores (que já traz o saldo anterior e cada lançamento), informa o código da empresa e anexa o arquivo. A ferramenta vincula débitos (pagamentos) e créditos (títulos/NFs) de cada fornecedor por valor e data e mostra, por fornecedor, o que ficou pendente, com alertas. O contador pode desfazer ou criar vínculos e marcar a conta como validada. Tudo fica salvo no histórico e pode ser exportado em XLSX.
|
||||||
|
|
||||||
|
**Permissão**: toggle único `apps["conciliacao-fornecedores"]` em `permissoes["utilitarios"]`, checado via `PermissaoApp("utilitarios", "conciliacao-fornecedores")` em todas as views. **Restrita ao perfil "Inovação"** por decisão do usuário (override em `seed_portal.py`, que não roda em produção: em produção a liberação é feita pela tela de Perfis de Acesso).
|
||||||
|
|
||||||
|
## Decisões de escopo (confirmadas com o usuário)
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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`).
|
||||||
|
- **Adiantamento a fornecedor** (contrapartida "ADIANTAMENTOS A FORNECEDORES") conta como pagamento normal (antecipado).
|
||||||
|
- **Débito sobrando é divergência, crédito em aberto é só título não pago** (pedido do usuário), com a ressalva da distribuição de parcelas abaixo.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
O cabeçalho é conferido pelas duas primeiras letras de cada coluna (sem acento), para tolerar "Seqüência" com acento corrompido.
|
||||||
|
|
||||||
|
## Motor (`motor.py`)
|
||||||
|
|
||||||
|
Função pura `concilia(itens, tolerancia) -> list[VinculoSugerido]`, uma conta por vez. O saldo anterior diferente de zero entra como um item a mais (vira `ConciliacaoFornecedorLancamento` com `eh_saldo_anterior=True`). O pagamento não cita a NF, então **todo vínculo é por valor e data**. Etapas:
|
||||||
|
|
||||||
|
1. **Segmentação por saldo zerado**: cada ponto em que o saldo corrente (na ordem do arquivo) volta a zero fecha um segmento, e vínculo nunca atravessa segmento. Isso evita casar o frete de janeiro com um pagamento de mesmo valor de julho.
|
||||||
|
2. **1:1 por valor exato**, menor distância de datas.
|
||||||
|
3. **Combinação exata** (vários débitos = um crédito, ou vários créditos, como NF + juros, = um débito): até 6 itens, janela de 120 dias, **só os 12 candidatos mais próximos em data**, teto de 20 mil nós por busca. O limite de candidatos existe porque, numa conta com centenas de lançamentos pequenos (EBAZAR, 456 no arquivo real), uma soma exata entre itens sem relação é quase certa se todos puderem participar.
|
||||||
|
4. **Compensação do segmento fechado**: o que sobrou num segmento que zerou vira um vínculo `saldo_zerado`.
|
||||||
|
5. **Tolerância**, só no último segmento (o que está em aberto): 1:1 ou 2 itens com diferença até o limite. Com 3 ou mais itens, combinações "a menos de R$ 10" aparecem por acaso.
|
||||||
|
|
||||||
|
Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensações, 21 com diferença.
|
||||||
|
|
||||||
|
## Situação e alertas (`alertas.py`)
|
||||||
|
|
||||||
|
**Nunca gravados**: recalculados a cada leitura por `analisa_conta()`, porque o contador pode desfazer ou criar vínculos. Só o `resumo` do cabeçalho (totais para a listagem do histórico) é gravado, e é recalculado a cada ajuste.
|
||||||
|
|
||||||
|
**Fato x sugestão: só vínculo muda valor** (pedido do usuário). Um título sem vínculo está em aberto pelo **valor cheio**; um pagamento sem vínculo é "Pagamento não vinculado". O razão não traz parcelamento, vencimento nem NF no pagamento, então qualquer dedução sobre qual título um pagamento paga é **sugestão**: vai em `SituacaoItem.sugestao` (texto à parte), nunca na `descricao` (só fato) nem em `valor_em_aberto`. Na tela, a sugestão aparece numa linha própria abaixo do fato, com a etiqueta "Sugestão", em itálico e cor neutra; no XLSX, depois de "Sugestão:". O contador confirma criando o vínculo manual (inclusive o de pagamento parcial, que aí sim reduz o valor em aberto).
|
||||||
|
|
||||||
|
As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: primeiro o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela), senão o título mais antigo com saldo. Ela serve só para escrever as sugestões e para achar o **excedente** (pagamentos não vinculados além de todos os títulos em aberto), que é fato e é a divergência. Motivo de existir: o arquivo real tem muitos parcelamentos (NF 1.299,00 em 3 × 433,00; NF 25.325,00 em 5 × 5.065,00), e a sugestão aponta ao contador o título provável.
|
||||||
|
|
||||||
|
| Categoria | Significado (fato) | Alerta |
|
||||||
|
|---|---|---|
|
||||||
|
| `em_aberto` | título sem pagamento vinculado (ou saldo de um pagamento parcial vinculado) | nenhum (conta fica "Pendente de pagamento") |
|
||||||
|
| `vencido` | idem, há mais de 3 meses até a data-base | média (`credito_vencido`, valor real) |
|
||||||
|
| `nao_vinculado` | pagamento ainda não vinculado a um título | média (`pagamento_sem_titulo`, "N pagamentos ainda não vinculados, somando R$ X") |
|
||||||
|
| `excedente` | pagamento além de todos os títulos em aberto | **alta (divergência)** |
|
||||||
|
|
||||||
|
Consequência nos totais: `total_em_aberto` (card "Títulos em aberto") é a soma real dos títulos sem baixa e pode passar do saldo contábil da conta quando há pagamentos a vincular. O card mostra os dois (`total_nao_vinculado` = "em pagamentos a vincular"); o saldo contábil continua na coluna "Saldo residual".
|
||||||
|
|
||||||
|
Alertas de conta: `saldo_divergente_arquivo`, `conta_transitoria`, `saldo_devedor` (alta); `debito_excedente` (alta); `credito_vencido`, `pagamento_sem_titulo` (média); `diferenca_vinculo` (baixa, vínculo automático por tolerância ou manual com diferença; diferença negativa = pago a maior, possível juros/multa; positiva = pago a menor, possível desconto).
|
||||||
|
|
||||||
|
Status: `divergente` (algum alerta alto) > `atencao` (médio/baixo) > `pendente` (só títulos recentes em aberto) > `conciliada`.
|
||||||
|
|
||||||
|
**Validação é uma marcação à parte e nunca muda a situação** (pedido do usuário): uma conta em "Atenção" validada continua "Atenção", com o checkbox marcado. Conta "Conciliada" já nasce validada (`_conciliacao_valida_conciliadas()` na criação). A validação automática é a que tem `validada_por` nulo (`validada_automatica` no payload): depois de vincular/desfazer, `_conciliacao_sincroniza_validacao_automatica()` a aplica se a conta ficou conciliada e a retira se deixou de estar; a validação feita por uma pessoa (endpoint `validar/`) nunca é mexida por essa regra.
|
||||||
|
|
||||||
|
## 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`) → `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).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
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/{id}/` | GET/DELETE | detalhe completo (contas + lançamentos + vínculos + análise, ~1 MB para 2.500 lançamentos) / 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-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, entra na distribuição como um título com a data do crédito mais antigo do vínculo e conta para o aging); 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-vinculos/{id}/` | DELETE | desfaz o vínculo |
|
||||||
|
| `/api/conciliacoes-fornecedores-vinculos/{id}/adicionar/` | POST | `{lancamentos: [ids]}` (1 ou mais): acrescenta lançamentos pendentes da mesma conta a um vínculo **manual ou parcial** (automático é 400), sem desfazer. Tipo e diferença recalculados por `_conciliacao_tipo_vinculo_manual()`, a mesma regra da criação (pode continuar parcial, fechar ou virar pago a maior); `criado_por` passa a ser quem acrescentou |
|
||||||
|
|
||||||
|
Os três endpoints de ajuste devolvem `{conta, resumo}`: a conta reanalisada e o resumo recalculado, para a tela atualizar só aquela linha e os cards.
|
||||||
|
|
||||||
|
## Tela (`conciliacao-fornecedores.html` / `.js` / `.css`)
|
||||||
|
|
||||||
|
Duas vistas na mesma página. **Histórico**: formulário de nova conciliação (o código da empresa busca o nome no Questor com debounce; o nome do arquivo com prefixo numérico sugere o código) + tabela do histórico. **Detalhe** (`#conciliacao-<id>` na URL), desenhado para ser leve (pedido do usuário, validado antes num protótipo):
|
||||||
|
- cabeçalho da empresa baixo, código e nome na mesma linha;
|
||||||
|
- **uma faixa de indicadores**: saldo em aberto, em aberto há mais de 3 meses (clica e filtra), pagamentos sem título (divergência, clica e filtra) e uma **barra de progresso da validação** ("146 de 260");
|
||||||
|
- os apontamentos menores (diferença de juros/desconto, pagamento sem título de mesmo valor, conta transitória) como **etiquetas** clicáveis numa linha, só quando existem;
|
||||||
|
- **os números por situação ficam só nos chips** (Todos/Divergentes/Atenção/Pendentes/Conciliados/Validados), que também filtram: não duplicar em card;
|
||||||
|
- **contas validadas escondidas por padrão** ("Mostrar validadas" ao lado da busca; o chip "Validados" mostra sempre), para a lista mostrar o que falta revisar;
|
||||||
|
- filtros e cabeçalho da tabela **fixos ao rolar** (`position: sticky` abaixo da `.topbar`; `.conc-table-wrap` desliga o `overflow:hidden` de `.pa-table-wrap`, que anularia o sticky);
|
||||||
|
- linhas compactas, com o fornecedor e o primeiro alerta cortados com reticências e a situação só como etiqueta.
|
||||||
|
- na conta expandida, a **barra de seleção** (soma de créditos/débitos, diferença e "Vincular selecionados") e o thead da lista de pendentes também ficam fixos, empilhados abaixo do thead da tabela de fornecedores (`--conc-thead-altura`/`--conc-selecao-altura` no `#conc-detail-view`; se a altura de um deles mudar, ajustar a variável). A `.conc-subtabela--pendentes` desliga o `overflow:hidden` pelo mesmo motivo de `.conc-table-wrap`.
|
||||||
|
|
||||||
|
A tabela de fornecedores, **ordenada por padrão pela situação, divergentes primeiro** (depois atenção, pendente, conciliada) (conta, fornecedor, saldo inicial, saldo residual, em aberto há mais de 3 meses, situação com ícone, e um **checkbox "Validado" no fim da linha**, pedido do usuário). O checkbox grava direto pelo mesmo `validar/` do botão "Marcar como validada" do detalhe, sem enviar `observacao` (o servidor mantém a já registrada), e o clique nele não expande a linha. **Quando a conta passa a validada** (por qualquer caminho, inclusive a validação automática), a linha expandida recolhe. Clicar numa linha expande: alertas, o **razão desde a primeira pendência** (todos os lançamentos, na ordem do arquivo e com a coluna de saldo do Questor, a partir da data do lançamento pendente mais antigo, incluindo os itens de pagamento parcial; os já vinculados aparecem apagados como "Vinculado #N" com o tipo, sem seleção, destacando o vínculo inteiro ao passar o mouse e com "Desfazer" na própria linha, para achar vínculo automático errado; um toggle **"Ocultar conciliados"** ao lado do título, **desmarcado por padrão** (pedido do usuário), esconde essas linhas vinculadas só por CSS, e a escolha fica em `estadoDe(conta.id).ocultarConciliados`, sobrevivendo ao re-render depois de vincular/desfazer), os **lançamentos não conciliados** (com checkbox para vínculo manual, valor em aberto e situação de cada um), lançamentos conciliados agrupados por vínculo (ocultos por padrão, com "Desfazer vínculo") e observação/validação da conta.
|
||||||
|
|
||||||
|
## Limitações conhecidas
|
||||||
|
|
||||||
|
- 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).
|
||||||
19
portal_api/conciliacao_fornecedores/README.md
Normal file
19
portal_api/conciliacao_fornecedores/README.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Conciliação de Fornecedores (Utilitários)
|
||||||
|
|
||||||
|
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico (motor de conciliação, alertas, API). Ver `CLAUDE.md` na raiz para a arquitetura geral do Portal.
|
||||||
|
|
||||||
|
Ferramenta para o contador conciliar as contas de fornecedores de uma empresa. Ele exporta do Questor o razão contábil de fornecedores (.xlsx ou .csv), informa o código da empresa (o nome vem do Questor) e anexa o arquivo. A ferramenta:
|
||||||
|
|
||||||
|
- vincula, por valor e data, os pagamentos (débitos) aos títulos (créditos) de cada fornecedor, inclusive vários pagamentos somando um título e parcelas;
|
||||||
|
- mostra, por fornecedor, conta, saldo inicial, saldo residual e a situação (conciliada, pendente de pagamento, atenção, divergente, validada);
|
||||||
|
- alerta títulos em aberto há mais de 2 meses, 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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
- `ConciliacaoFornecedor`, `ConciliacaoFornecedorConta`, `ConciliacaoFornecedorVinculo`, `ConciliacaoFornecedorLancamento` (`portal_api/models.py`).
|
||||||
|
- `conciliacao-fornecedores.html` / `static/js/conciliacao-fornecedores.js` / `static/css/conciliacao-fornecedores.css`.
|
||||||
0
portal_api/conciliacao_fornecedores/__init__.py
Normal file
0
portal_api/conciliacao_fornecedores/__init__.py
Normal file
331
portal_api/conciliacao_fornecedores/alertas.py
Normal file
331
portal_api/conciliacao_fornecedores/alertas.py
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
"""Situação de uma conta de fornecedor a partir dos vínculos atuais
|
||||||
|
(automáticos ou manuais) — recalculada a cada exibição, nunca gravada,
|
||||||
|
porque o contador pode desfazer ou criar vínculos a qualquer momento.
|
||||||
|
|
||||||
|
**Fato x sugestão** (pedido do usuário): valores só mudam por vínculo. Um
|
||||||
|
título sem vínculo está em aberto pelo **valor cheio**, e um pagamento sem
|
||||||
|
vínculo é "pagamento não vinculado", mesmo que a ferramenta desconfie de
|
||||||
|
qual título ele paga. Essa desconfiança sai numa distribuição dos
|
||||||
|
pagamentos não vinculados sobre os títulos em aberto (primeiro o título do
|
||||||
|
qual o pagamento parece ser parcela, título ≈ k × pagamento com k de 2 a
|
||||||
|
`MAX_PARCELAS`; senão o mais antigo com saldo), usada **só** para:
|
||||||
|
|
||||||
|
- escrever a sugestão de cada item (`SituacaoItem.sugestao`, texto à parte
|
||||||
|
da `descricao`, que é só fato);
|
||||||
|
- achar o pagamento que excede todos os títulos em aberto, que é fato
|
||||||
|
(a soma dos pagamentos não vinculados passa da soma dos títulos) e é a
|
||||||
|
**divergência**.
|
||||||
|
|
||||||
|
O saldo de um vínculo manual de pagamento parcial (`TIPO_PARCIAL`) entra
|
||||||
|
como um título a mais, com a data do título original: esse sim já é um
|
||||||
|
valor confirmado pelo contador.
|
||||||
|
|
||||||
|
Python puro, sem ORM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA
|
||||||
|
|
||||||
|
MESES_VENCIDO = 3
|
||||||
|
MAX_PARCELAS = 24
|
||||||
|
|
||||||
|
# Contas que não deveriam carregar saldo (não são fornecedores de fato).
|
||||||
|
# Comparado sem acento, por trecho do nome da conta.
|
||||||
|
TRECHOS_CONTA_TRANSITORIA = ("TRANSFERENCIA PARA CUSTOS", "TRANSITORI")
|
||||||
|
|
||||||
|
SEVERIDADE_ALTA = "alta"
|
||||||
|
SEVERIDADE_MEDIA = "media"
|
||||||
|
SEVERIDADE_BAIXA = "baixa"
|
||||||
|
|
||||||
|
STATUS_CONCILIADA = "conciliada"
|
||||||
|
STATUS_PENDENTE = "pendente"
|
||||||
|
STATUS_ATENCAO = "atencao"
|
||||||
|
STATUS_DIVERGENTE = "divergente"
|
||||||
|
|
||||||
|
CATEGORIA_EM_ABERTO = "em_aberto"
|
||||||
|
CATEGORIA_VENCIDO = "vencido"
|
||||||
|
CATEGORIA_NAO_VINCULADO = "nao_vinculado" # pagamento sem título vinculado
|
||||||
|
CATEGORIA_EXCEDENTE = "excedente" # pagamento que passa de todos os títulos em aberto
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ItemAnalise:
|
||||||
|
id: int
|
||||||
|
data: date
|
||||||
|
natureza: str # "C" ou "D"
|
||||||
|
valor: Decimal
|
||||||
|
vinculo_id: int | None
|
||||||
|
descricao: str # histórico, para citar o título abatido
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VinculoAnalise:
|
||||||
|
id: int
|
||||||
|
tipo: str
|
||||||
|
diferenca: Decimal
|
||||||
|
# data do título (crédito) mais antigo do vínculo: é a data que conta
|
||||||
|
# para o aging do saldo de um pagamento parcial
|
||||||
|
data_titulo: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlertaConta:
|
||||||
|
tipo: str
|
||||||
|
severidade: str
|
||||||
|
mensagem: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SituacaoItem:
|
||||||
|
categoria: str
|
||||||
|
# Crédito: valor real em aberto (cheio, ou o saldo de um pagamento
|
||||||
|
# parcial vinculado). Débito: parte excedente (só na divergência).
|
||||||
|
valor_em_aberto: Decimal
|
||||||
|
descricao: str # só fato
|
||||||
|
sugestao: str = "" # dedução da ferramenta, a confirmar com um vínculo
|
||||||
|
relacionados: list[int] = field(default_factory=list) # ids do outro lado na sugestão
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnaliseConta:
|
||||||
|
status: str
|
||||||
|
alertas: list[AlertaConta]
|
||||||
|
situacoes: dict[int, SituacaoItem]
|
||||||
|
total_em_aberto: Decimal # títulos em aberto, valor real
|
||||||
|
total_vencido: Decimal # títulos em aberto há mais de MESES_VENCIDO meses, valor real
|
||||||
|
total_nao_vinculado: Decimal # pagamentos ainda não vinculados (sem o excedente)
|
||||||
|
total_excedente: Decimal # pagamentos além de todos os títulos em aberto
|
||||||
|
|
||||||
|
|
||||||
|
def _moeda(valor: Decimal) -> str:
|
||||||
|
texto = f"{abs(valor):,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||||
|
return f"R$ {texto}"
|
||||||
|
|
||||||
|
|
||||||
|
def _sem_acento(texto: str) -> str:
|
||||||
|
normalizado = unicodedata.normalize("NFKD", texto)
|
||||||
|
return "".join(c for c in normalizado if not unicodedata.combining(c)).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def eh_conta_transitoria(nome_conta: str) -> bool:
|
||||||
|
nome = _sem_acento(nome_conta)
|
||||||
|
return any(trecho in nome for trecho in TRECHOS_CONTA_TRANSITORIA)
|
||||||
|
|
||||||
|
|
||||||
|
def _soma_meses(data: date, meses: int) -> date:
|
||||||
|
mes = data.month - 1 + meses
|
||||||
|
ano = data.year + mes // 12
|
||||||
|
mes = mes % 12 + 1
|
||||||
|
dias_no_mes = [31, 29 if ano % 4 == 0 and (ano % 100 != 0 or ano % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
return date(ano, mes, min(data.day, dias_no_mes[mes - 1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _eh_parcela(valor_titulo: Decimal, valor_pagamento: Decimal) -> bool:
|
||||||
|
if valor_pagamento <= 0 or valor_pagamento >= valor_titulo:
|
||||||
|
return False
|
||||||
|
k = round(valor_titulo / valor_pagamento)
|
||||||
|
if k < 2 or k > MAX_PARCELAS:
|
||||||
|
return False
|
||||||
|
# arredondamento de parcela: até 1 centavo por parcela
|
||||||
|
return abs(valor_titulo - valor_pagamento * k) <= Decimal("0.01") * k
|
||||||
|
|
||||||
|
|
||||||
|
def _rotulo_titulo(credito: ItemAnalise) -> str:
|
||||||
|
return f"{credito.data:%d/%m/%Y} ({_moeda(credito.valor)})"
|
||||||
|
|
||||||
|
|
||||||
|
def _analisa_pendentes(
|
||||||
|
itens: list[ItemAnalise], data_base: date
|
||||||
|
) -> tuple[dict[int, SituacaoItem], Decimal, Decimal, Decimal, Decimal]:
|
||||||
|
creditos = sorted((i for i in itens if i.natureza == "C"), key=lambda i: (i.data, i.id))
|
||||||
|
debitos = sorted((i for i in itens if i.natureza == "D"), key=lambda i: (i.data, i.id))
|
||||||
|
|
||||||
|
# Distribuição sugerida: não altera nenhum valor exibido, só alimenta os
|
||||||
|
# textos de sugestão e a detecção do excedente.
|
||||||
|
saldo_sugerido = {c.id: c.valor for c in creditos}
|
||||||
|
pagos_sugeridos: dict[int, list[int]] = {c.id: [] for c in creditos}
|
||||||
|
situacoes: dict[int, SituacaoItem] = {}
|
||||||
|
total_nao_vinculado = Decimal("0")
|
||||||
|
total_excedente = Decimal("0")
|
||||||
|
|
||||||
|
for debito in debitos:
|
||||||
|
restante = debito.valor
|
||||||
|
usados: list[int] = []
|
||||||
|
parcela_de = next(
|
||||||
|
(c for c in creditos if saldo_sugerido[c.id] >= debito.valor and _eh_parcela(c.valor, debito.valor)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
ordem = ([parcela_de] if parcela_de else []) + [c for c in creditos if c is not parcela_de]
|
||||||
|
for credito in ordem:
|
||||||
|
if restante <= 0:
|
||||||
|
break
|
||||||
|
if saldo_sugerido[credito.id] <= 0:
|
||||||
|
continue
|
||||||
|
abatido = min(restante, saldo_sugerido[credito.id])
|
||||||
|
saldo_sugerido[credito.id] -= abatido
|
||||||
|
restante -= abatido
|
||||||
|
usados.append(credito.id)
|
||||||
|
pagos_sugeridos[credito.id].append(debito.id)
|
||||||
|
|
||||||
|
titulos = [c for c in creditos if c.id in usados]
|
||||||
|
if parcela_de and usados == [parcela_de.id]:
|
||||||
|
sugestao = f"Possível parcela do título de {_rotulo_titulo(parcela_de)}. Vincule para confirmar."
|
||||||
|
elif titulos:
|
||||||
|
sugestao = f"Pode ser pagamento do(s) título(s) de {', '.join(_rotulo_titulo(c) for c in titulos)}."
|
||||||
|
else:
|
||||||
|
sugestao = ""
|
||||||
|
|
||||||
|
if restante > 0:
|
||||||
|
total_excedente += restante
|
||||||
|
total_nao_vinculado += debito.valor - restante
|
||||||
|
situacoes[debito.id] = SituacaoItem(
|
||||||
|
CATEGORIA_EXCEDENTE,
|
||||||
|
restante,
|
||||||
|
f"Pagamento sem título em aberto: {_moeda(restante)} excedente.",
|
||||||
|
sugestao,
|
||||||
|
usados,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total_nao_vinculado += debito.valor
|
||||||
|
situacoes[debito.id] = SituacaoItem(CATEGORIA_NAO_VINCULADO, Decimal("0"), "Pagamento não vinculado.", sugestao, usados)
|
||||||
|
|
||||||
|
total_aberto = Decimal("0")
|
||||||
|
total_vencido = Decimal("0")
|
||||||
|
for credito in creditos:
|
||||||
|
pagos = pagos_sugeridos[credito.id]
|
||||||
|
coberto = credito.valor - saldo_sugerido[credito.id]
|
||||||
|
if not pagos:
|
||||||
|
sugestao = ""
|
||||||
|
elif saldo_sugerido[credito.id] <= 0:
|
||||||
|
sugestao = f"Pode ter sido quitado por {len(pagos)} pagamento(s) não vinculado(s). Vincule para confirmar."
|
||||||
|
else:
|
||||||
|
sugestao = f"Pode ter sido pago parcialmente ({_moeda(coberto)}) por pagamento(s) não vinculado(s)."
|
||||||
|
total_aberto += credito.valor
|
||||||
|
if _soma_meses(credito.data, MESES_VENCIDO) < data_base:
|
||||||
|
total_vencido += credito.valor
|
||||||
|
situacoes[credito.id] = SituacaoItem(
|
||||||
|
CATEGORIA_VENCIDO, credito.valor, f"Em aberto há mais de {MESES_VENCIDO} meses.", sugestao, pagos
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
situacoes[credito.id] = SituacaoItem(CATEGORIA_EM_ABERTO, credito.valor, "Em aberto.", sugestao, pagos)
|
||||||
|
|
||||||
|
return situacoes, total_aberto, total_vencido, total_nao_vinculado, total_excedente
|
||||||
|
|
||||||
|
|
||||||
|
def analisa_conta(
|
||||||
|
itens: list[ItemAnalise],
|
||||||
|
vinculos: list[VinculoAnalise],
|
||||||
|
*,
|
||||||
|
nome_conta: str,
|
||||||
|
saldo_final: Decimal,
|
||||||
|
saldo_final_arquivo: Decimal,
|
||||||
|
data_base: date,
|
||||||
|
) -> AnaliseConta:
|
||||||
|
"""`saldo_final` é o recalculado (saldo anterior + créditos - débitos);
|
||||||
|
`saldo_final_arquivo` o último impresso pelo Questor. Credor positivo."""
|
||||||
|
pendentes = [i for i in itens if i.vinculo_id is None]
|
||||||
|
# O saldo de cada pagamento parcial vinculado à mão continua em aberto:
|
||||||
|
# entra como um título a mais (id negativo = -id do vínculo), com a data
|
||||||
|
# do título original para o aging.
|
||||||
|
for vinculo in vinculos:
|
||||||
|
if vinculo.tipo == TIPO_PARCIAL and vinculo.diferenca > 0 and vinculo.data_titulo:
|
||||||
|
pendentes.append(
|
||||||
|
ItemAnalise(
|
||||||
|
id=-vinculo.id,
|
||||||
|
data=vinculo.data_titulo,
|
||||||
|
natureza="C",
|
||||||
|
valor=vinculo.diferenca,
|
||||||
|
vinculo_id=None,
|
||||||
|
descricao="Saldo do pagamento parcial",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
situacoes, total_aberto, total_vencido, total_nao_vinculado, total_excedente = _analisa_pendentes(
|
||||||
|
pendentes, data_base
|
||||||
|
)
|
||||||
|
alertas: list[AlertaConta] = []
|
||||||
|
|
||||||
|
if saldo_final != saldo_final_arquivo:
|
||||||
|
alertas.append(
|
||||||
|
AlertaConta(
|
||||||
|
"saldo_divergente_arquivo",
|
||||||
|
SEVERIDADE_ALTA,
|
||||||
|
f"O saldo recalculado ({_moeda(saldo_final)}) difere do último saldo do arquivo "
|
||||||
|
f"({_moeda(saldo_final_arquivo)}). Confira se a exportação veio completa.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if eh_conta_transitoria(nome_conta) and saldo_final != 0:
|
||||||
|
alertas.append(
|
||||||
|
AlertaConta(
|
||||||
|
"conta_transitoria",
|
||||||
|
SEVERIDADE_ALTA,
|
||||||
|
f"Conta transitória com saldo de {_moeda(saldo_final)} "
|
||||||
|
f"{'devedor' if saldo_final < 0 else 'credor'}; deveria estar zerada.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif saldo_final < 0:
|
||||||
|
alertas.append(
|
||||||
|
AlertaConta("saldo_devedor", SEVERIDADE_ALTA, f"Saldo devedor de {_moeda(saldo_final)} no fornecedor.")
|
||||||
|
)
|
||||||
|
if total_excedente > 0:
|
||||||
|
quantidade = sum(1 for s in situacoes.values() if s.categoria == CATEGORIA_EXCEDENTE)
|
||||||
|
alertas.append(
|
||||||
|
AlertaConta(
|
||||||
|
"debito_excedente",
|
||||||
|
SEVERIDADE_ALTA,
|
||||||
|
f"{quantidade} pagamento(s) sem título em aberto correspondente, somando {_moeda(total_excedente)}.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if total_vencido > 0:
|
||||||
|
quantidade = sum(1 for s in situacoes.values() if s.categoria == CATEGORIA_VENCIDO)
|
||||||
|
alertas.append(
|
||||||
|
AlertaConta(
|
||||||
|
"credito_vencido",
|
||||||
|
SEVERIDADE_MEDIA,
|
||||||
|
f"{quantidade} título(s) em aberto há mais de {MESES_VENCIDO} meses, somando {_moeda(total_vencido)}.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if total_nao_vinculado > 0:
|
||||||
|
quantidade = sum(1 for s in situacoes.values() if s.categoria == CATEGORIA_NAO_VINCULADO)
|
||||||
|
alertas.append(
|
||||||
|
AlertaConta(
|
||||||
|
"pagamento_sem_titulo",
|
||||||
|
SEVERIDADE_MEDIA,
|
||||||
|
f"{quantidade} pagamento(s) ainda não vinculado(s) a um título, somando {_moeda(total_nao_vinculado)}. "
|
||||||
|
"Veja as sugestões na lista.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for vinculo in vinculos:
|
||||||
|
if vinculo.diferenca == 0 or vinculo.tipo not in (TIPO_TOLERANCIA, TIPO_MANUAL):
|
||||||
|
continue
|
||||||
|
# diferença = créditos - débitos: negativa é pago a maior
|
||||||
|
natureza = "pago a maior (possível juros/multa)" if vinculo.diferenca < 0 else "pago a menor (possível desconto)"
|
||||||
|
origem = "Vínculo manual" if vinculo.tipo == TIPO_MANUAL else "Vínculo"
|
||||||
|
alertas.append(
|
||||||
|
AlertaConta("diferenca_vinculo", SEVERIDADE_BAIXA, f"{origem} com diferença de {_moeda(vinculo.diferenca)}: {natureza}.")
|
||||||
|
)
|
||||||
|
|
||||||
|
severidades = {a.severidade for a in alertas}
|
||||||
|
if SEVERIDADE_ALTA in severidades:
|
||||||
|
status = STATUS_DIVERGENTE
|
||||||
|
elif severidades:
|
||||||
|
status = STATUS_ATENCAO
|
||||||
|
elif total_aberto > 0:
|
||||||
|
status = STATUS_PENDENTE
|
||||||
|
else:
|
||||||
|
status = STATUS_CONCILIADA
|
||||||
|
|
||||||
|
return AnaliseConta(
|
||||||
|
status=status,
|
||||||
|
alertas=alertas,
|
||||||
|
situacoes=situacoes,
|
||||||
|
total_em_aberto=total_aberto,
|
||||||
|
total_vencido=total_vencido,
|
||||||
|
total_nao_vinculado=total_nao_vinculado,
|
||||||
|
total_excedente=total_excedente,
|
||||||
|
)
|
||||||
177
portal_api/conciliacao_fornecedores/exportacao.py
Normal file
177
portal_api/conciliacao_fornecedores/exportacao.py
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"""Exportação em XLSX do resultado de uma conciliação de fornecedores —
|
||||||
|
funções puras (openpyxl), recebem os dados já resolvidos pela view, nunca
|
||||||
|
tocam no ORM (mesmo espírito de `dashboard_contabil/exportacao.py`).
|
||||||
|
|
||||||
|
Três abas: Resumo (uma linha por conta), Pendências (cada lançamento sem
|
||||||
|
vínculo, com o valor em aberto e a situação) e Alertas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
|
||||||
|
# Mesma paleta roxo/dourado dos outros documentos gerados pelo escritório.
|
||||||
|
COR_ROXO_ESCURO = "FF3D2178"
|
||||||
|
COR_BRANCO = "FFFFFFFF"
|
||||||
|
FORMATO_MOEDA = '"R$" #,##0.00;[Red]-"R$" #,##0.00'
|
||||||
|
FORMATO_DATA = "DD/MM/YYYY"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ContaXlsx:
|
||||||
|
conta_numero: str
|
||||||
|
classificacao: str
|
||||||
|
fornecedor: str
|
||||||
|
saldo_inicial: Decimal
|
||||||
|
saldo_final: Decimal
|
||||||
|
total_em_aberto: Decimal
|
||||||
|
total_vencido: Decimal
|
||||||
|
total_excedente: Decimal
|
||||||
|
situacao: str
|
||||||
|
validada: bool
|
||||||
|
observacao: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendenciaXlsx:
|
||||||
|
conta_numero: str
|
||||||
|
fornecedor: str
|
||||||
|
data: date
|
||||||
|
sequencia: str
|
||||||
|
historico: str
|
||||||
|
natureza: str # "Crédito"/"Débito"
|
||||||
|
valor: Decimal
|
||||||
|
valor_em_aberto: Decimal
|
||||||
|
situacao: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlertaXlsx:
|
||||||
|
conta_numero: str
|
||||||
|
fornecedor: str
|
||||||
|
severidade: str
|
||||||
|
mensagem: str
|
||||||
|
|
||||||
|
|
||||||
|
def _cabecalho(ws: Worksheet, titulo: str, empresa: str, subtitulo: str, colunas: list[str]) -> int:
|
||||||
|
ultima = get_column_letter(len(colunas))
|
||||||
|
ws.merge_cells(f"A1:{ultima}1")
|
||||||
|
ws["A1"] = titulo
|
||||||
|
ws["A1"].font = Font(size=14, bold=True, color=COR_ROXO_ESCURO)
|
||||||
|
ws.merge_cells(f"A2:{ultima}2")
|
||||||
|
ws["A2"] = empresa
|
||||||
|
ws["A2"].font = Font(size=11, bold=True)
|
||||||
|
ws.merge_cells(f"A3:{ultima}3")
|
||||||
|
ws["A3"] = subtitulo
|
||||||
|
ws["A3"].font = Font(size=9, italic=True)
|
||||||
|
|
||||||
|
linha = 5
|
||||||
|
for coluna, texto in enumerate(colunas, start=1):
|
||||||
|
celula = ws.cell(row=linha, column=coluna, value=texto)
|
||||||
|
celula.font = Font(bold=True, color=COR_BRANCO)
|
||||||
|
celula.fill = PatternFill("solid", fgColor=COR_ROXO_ESCURO)
|
||||||
|
celula.alignment = Alignment(vertical="center", wrap_text=True)
|
||||||
|
ws.freeze_panes = ws.cell(row=linha + 1, column=1)
|
||||||
|
return linha + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _larguras(ws: Worksheet, larguras: list[int]) -> None:
|
||||||
|
for coluna, largura in enumerate(larguras, start=1):
|
||||||
|
ws.column_dimensions[get_column_letter(coluna)].width = largura
|
||||||
|
|
||||||
|
|
||||||
|
def gera_xlsx(
|
||||||
|
*,
|
||||||
|
empresa: str,
|
||||||
|
data_base: date,
|
||||||
|
periodo: str,
|
||||||
|
contas: list[ContaXlsx],
|
||||||
|
pendencias: list[PendenciaXlsx],
|
||||||
|
alertas: list[AlertaXlsx],
|
||||||
|
) -> bytes:
|
||||||
|
subtitulo = f"Data-base {data_base:%d/%m/%Y} · Período {periodo}"
|
||||||
|
wb = Workbook()
|
||||||
|
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Resumo"
|
||||||
|
colunas = [
|
||||||
|
"Conta",
|
||||||
|
"Classificação",
|
||||||
|
"Fornecedor",
|
||||||
|
"Saldo inicial",
|
||||||
|
"Saldo final",
|
||||||
|
"Em aberto",
|
||||||
|
"Em aberto há mais de 3 meses",
|
||||||
|
"Pagamento excedente",
|
||||||
|
"Situação",
|
||||||
|
"Validada",
|
||||||
|
"Observação",
|
||||||
|
]
|
||||||
|
linha = _cabecalho(ws, "Conciliação de Fornecedores - Resumo", empresa, subtitulo, colunas)
|
||||||
|
for conta in contas:
|
||||||
|
valores = [
|
||||||
|
conta.conta_numero,
|
||||||
|
conta.classificacao,
|
||||||
|
conta.fornecedor,
|
||||||
|
conta.saldo_inicial,
|
||||||
|
conta.saldo_final,
|
||||||
|
conta.total_em_aberto,
|
||||||
|
conta.total_vencido,
|
||||||
|
conta.total_excedente,
|
||||||
|
conta.situacao,
|
||||||
|
"Sim" if conta.validada else "Não",
|
||||||
|
conta.observacao,
|
||||||
|
]
|
||||||
|
for coluna, valor in enumerate(valores, start=1):
|
||||||
|
celula = ws.cell(row=linha, column=coluna, value=valor)
|
||||||
|
if 4 <= coluna <= 8:
|
||||||
|
celula.number_format = FORMATO_MOEDA
|
||||||
|
linha += 1
|
||||||
|
_larguras(ws, [12, 18, 45, 16, 16, 16, 18, 18, 20, 10, 50])
|
||||||
|
|
||||||
|
ws = wb.create_sheet("Pendências")
|
||||||
|
colunas = ["Conta", "Fornecedor", "Data", "Seqüência", "Histórico", "Natureza", "Valor", "Valor em aberto", "Situação"]
|
||||||
|
linha = _cabecalho(ws, "Conciliação de Fornecedores - Pendências", empresa, subtitulo, colunas)
|
||||||
|
for item in pendencias:
|
||||||
|
valores = [
|
||||||
|
item.conta_numero,
|
||||||
|
item.fornecedor,
|
||||||
|
item.data,
|
||||||
|
item.sequencia,
|
||||||
|
item.historico,
|
||||||
|
item.natureza,
|
||||||
|
item.valor,
|
||||||
|
item.valor_em_aberto,
|
||||||
|
item.situacao,
|
||||||
|
]
|
||||||
|
for coluna, valor in enumerate(valores, start=1):
|
||||||
|
celula = ws.cell(row=linha, column=coluna, value=valor)
|
||||||
|
if coluna == 3:
|
||||||
|
celula.number_format = FORMATO_DATA
|
||||||
|
elif coluna in (7, 8):
|
||||||
|
celula.number_format = FORMATO_MOEDA
|
||||||
|
linha += 1
|
||||||
|
_larguras(ws, [12, 40, 12, 12, 50, 10, 16, 16, 60])
|
||||||
|
|
||||||
|
ws = wb.create_sheet("Alertas")
|
||||||
|
colunas = ["Conta", "Fornecedor", "Severidade", "Alerta"]
|
||||||
|
linha = _cabecalho(ws, "Conciliação de Fornecedores - Alertas", empresa, subtitulo, colunas)
|
||||||
|
for alerta in alertas:
|
||||||
|
for coluna, valor in enumerate(
|
||||||
|
[alerta.conta_numero, alerta.fornecedor, alerta.severidade, alerta.mensagem], start=1
|
||||||
|
):
|
||||||
|
ws.cell(row=linha, column=coluna, value=valor)
|
||||||
|
linha += 1
|
||||||
|
_larguras(ws, [12, 45, 12, 90])
|
||||||
|
|
||||||
|
saida = io.BytesIO()
|
||||||
|
wb.save(saida)
|
||||||
|
return saida.getvalue()
|
||||||
273
portal_api/conciliacao_fornecedores/motor.py
Normal file
273
portal_api/conciliacao_fornecedores/motor.py
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
"""Motor de conciliação automática de uma conta de fornecedor.
|
||||||
|
|
||||||
|
Recebe os itens da conta (o saldo anterior, quando diferente de zero, entra
|
||||||
|
como um item a mais, datado do dia do saldo) e devolve os vínculos
|
||||||
|
sugeridos. Python puro, sem ORM: a view persiste o resultado.
|
||||||
|
|
||||||
|
O histórico do pagamento não traz o número da NF (só "PAGAMENTO
|
||||||
|
<FORNECEDOR>"), então todo vínculo é por **valor e data**. As etapas, na
|
||||||
|
ordem:
|
||||||
|
|
||||||
|
1. **Segmentação por saldo zerado.** Todo ponto em que o saldo corrente da
|
||||||
|
conta volta a zero fecha um segmento: tudo antes dele se compensa
|
||||||
|
exatamente, e nenhum vínculo precisa atravessar esse ponto. O vínculo é
|
||||||
|
procurado só dentro de cada segmento, o que evita casar um frete de
|
||||||
|
janeiro com o pagamento de um frete de mesmo valor de julho.
|
||||||
|
2. **1:1 por valor exato**, escolhendo a menor distância de datas.
|
||||||
|
3. **Combinação exata**: vários débitos somando um crédito (ou vários
|
||||||
|
créditos, como NF + juros, somando um débito), limitada a
|
||||||
|
`MAX_ITENS_COMBINACAO` itens numa janela de `JANELA_DIAS_COMBINACAO`
|
||||||
|
dias, com teto de nós visitados por busca (subset-sum é exponencial).
|
||||||
|
4. **Compensação do segmento fechado**: o que sobrou num segmento que zerou
|
||||||
|
o saldo se compensa por definição; vira um único vínculo.
|
||||||
|
5. **Tolerância** (só no último segmento, o que ficou em aberto): 1:1 ou
|
||||||
|
combinação pequena com diferença até o limite configurado (valor fixo
|
||||||
|
**ou** percentual sobre o valor do título, o que for maior), sinalizada
|
||||||
|
como possível juros/desconto.
|
||||||
|
|
||||||
|
O que sobrar fica pendente, sem vínculo gravado. `alertas.py` distribui os
|
||||||
|
débitos pendentes sobre os créditos pendentes (parcelas, pagamento parcial)
|
||||||
|
a cada exibição, e só o que exceder os títulos em aberto vira divergência.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
MAX_ITENS_COMBINACAO = 6
|
||||||
|
JANELA_DIAS_COMBINACAO = 120
|
||||||
|
# Só os N candidatos mais próximos em data entram na busca de combinação:
|
||||||
|
# numa conta com centenas de lançamentos pequenos (marketplace, frete), uma
|
||||||
|
# soma exata entre itens sem relação nenhuma é quase certa se todos
|
||||||
|
# puderem participar.
|
||||||
|
MAX_CANDIDATOS_COMBINACAO = 12
|
||||||
|
# Com tolerância, uma combinação de 3 ou mais itens "a menos de R$ 10" do
|
||||||
|
# alvo aparece por acaso com muita facilidade; fica em 1:1 ou 2 itens
|
||||||
|
# (título + juros, ou dois pagamentos).
|
||||||
|
MAX_ITENS_TOLERANCIA = 2
|
||||||
|
MAX_NOS_BUSCA = 20000
|
||||||
|
# Tolerância de diferença (possível juros/desconto): fixa no Portal, não
|
||||||
|
# editável pela tela (decisão do usuário). Vale o maior dos dois limites.
|
||||||
|
TOLERANCIA_VALOR_PADRAO = Decimal("10.00")
|
||||||
|
TOLERANCIA_PERCENTUAL_PADRAO = Decimal("2.00")
|
||||||
|
|
||||||
|
TIPO_EXATO = "exato"
|
||||||
|
TIPO_COMBINACAO = "combinacao"
|
||||||
|
TIPO_SALDO_ZERADO = "saldo_zerado"
|
||||||
|
TIPO_TOLERANCIA = "tolerancia"
|
||||||
|
TIPO_MANUAL = "manual"
|
||||||
|
# Só manual: débitos menores que os créditos além da tolerância. O saldo
|
||||||
|
# (`diferenca`) continua em aberto (ver `alertas.analisa_conta`).
|
||||||
|
TIPO_PARCIAL = "parcial"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ItemConciliacao:
|
||||||
|
indice: int # posição na lista de entrada, devolvida nos vínculos
|
||||||
|
data: date
|
||||||
|
natureza: str # "C" (título/obrigação) ou "D" (pagamento)
|
||||||
|
valor: Decimal # sempre positivo
|
||||||
|
|
||||||
|
@property
|
||||||
|
def centavos(self) -> int:
|
||||||
|
return int((self.valor * 100).to_integral_value())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def assinado(self) -> int:
|
||||||
|
return self.centavos if self.natureza == "C" else -self.centavos
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VinculoSugerido:
|
||||||
|
tipo: str
|
||||||
|
indices: list[int]
|
||||||
|
diferenca: Decimal # soma dos créditos - soma dos débitos
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tolerancia:
|
||||||
|
valor: Decimal
|
||||||
|
percentual: Decimal # 2 = 2%
|
||||||
|
|
||||||
|
def limite_centavos(self, centavos_titulo: int) -> int:
|
||||||
|
fixo = int((self.valor * 100).to_integral_value())
|
||||||
|
proporcional = int(Decimal(centavos_titulo) * self.percentual / Decimal(100))
|
||||||
|
return max(fixo, proporcional)
|
||||||
|
|
||||||
|
|
||||||
|
def _diferenca(itens: list[ItemConciliacao]) -> Decimal:
|
||||||
|
return Decimal(sum(i.assinado for i in itens)) / Decimal(100)
|
||||||
|
|
||||||
|
|
||||||
|
def _vinculo(tipo: str, itens: list[ItemConciliacao]) -> VinculoSugerido:
|
||||||
|
return VinculoSugerido(tipo=tipo, indices=sorted(i.indice for i in itens), diferenca=_diferenca(itens))
|
||||||
|
|
||||||
|
|
||||||
|
def _segmentos(itens: list[ItemConciliacao]) -> list[list[ItemConciliacao]]:
|
||||||
|
"""Corta a lista (na ordem do arquivo) a cada ponto de saldo zero. O
|
||||||
|
último segmento é o que ficou em aberto (pode ser vazio)."""
|
||||||
|
segmentos: list[list[ItemConciliacao]] = []
|
||||||
|
atual: list[ItemConciliacao] = []
|
||||||
|
saldo = 0
|
||||||
|
for item in itens:
|
||||||
|
atual.append(item)
|
||||||
|
saldo += item.assinado
|
||||||
|
if saldo == 0:
|
||||||
|
segmentos.append(atual)
|
||||||
|
atual = []
|
||||||
|
segmentos.append(atual)
|
||||||
|
return segmentos
|
||||||
|
|
||||||
|
|
||||||
|
def _distancia(a: ItemConciliacao, b: ItemConciliacao) -> int:
|
||||||
|
return abs((a.data - b.data).days)
|
||||||
|
|
||||||
|
|
||||||
|
def _pareia_exato(pendentes: list[ItemConciliacao]) -> list[VinculoSugerido]:
|
||||||
|
creditos = [i for i in pendentes if i.natureza == "C"]
|
||||||
|
debitos = [i for i in pendentes if i.natureza == "D"]
|
||||||
|
por_valor: dict[int, list[ItemConciliacao]] = {}
|
||||||
|
for debito in debitos:
|
||||||
|
por_valor.setdefault(debito.centavos, []).append(debito)
|
||||||
|
|
||||||
|
candidatos: list[tuple[int, int, int, ItemConciliacao, ItemConciliacao]] = []
|
||||||
|
for credito in creditos:
|
||||||
|
for debito in por_valor.get(credito.centavos, []):
|
||||||
|
# Desempate: pagamento depois do título antes de pagamento
|
||||||
|
# adiantado, depois ordem de leitura.
|
||||||
|
antes = 1 if debito.data < credito.data else 0
|
||||||
|
candidatos.append((_distancia(credito, debito), antes, credito.indice, credito, debito))
|
||||||
|
candidatos.sort(key=lambda c: (c[0], c[1], c[2], c[4].indice))
|
||||||
|
|
||||||
|
usados: set[int] = set()
|
||||||
|
vinculos: list[VinculoSugerido] = []
|
||||||
|
for _, _, _, credito, debito in candidatos:
|
||||||
|
if credito.indice in usados or debito.indice in usados:
|
||||||
|
continue
|
||||||
|
usados.update((credito.indice, debito.indice))
|
||||||
|
vinculos.append(_vinculo(TIPO_EXATO, [credito, debito]))
|
||||||
|
return vinculos
|
||||||
|
|
||||||
|
|
||||||
|
def _busca_combinacao(
|
||||||
|
alvo: int,
|
||||||
|
candidatos: list[ItemConciliacao],
|
||||||
|
max_itens: int,
|
||||||
|
tolerancia: int = 0,
|
||||||
|
) -> list[ItemConciliacao] | None:
|
||||||
|
"""Subconjunto de `candidatos` (2 a `max_itens` itens) cuja soma fica a
|
||||||
|
até `tolerancia` centavos de `alvo`. Com tolerância, devolve o de menor
|
||||||
|
diferença encontrado dentro do teto de nós; sem, o primeiro exato.
|
||||||
|
`candidatos` já vem na ordem de preferência (mais próximos primeiro)."""
|
||||||
|
valores = [c.centavos for c in candidatos]
|
||||||
|
# soma dos maiores restantes a partir de cada posição, para podar ramos
|
||||||
|
# que não alcançam o alvo nem somando tudo o que resta
|
||||||
|
restante = [0] * (len(valores) + 1)
|
||||||
|
for pos in range(len(valores) - 1, -1, -1):
|
||||||
|
restante[pos] = restante[pos + 1] + valores[pos]
|
||||||
|
|
||||||
|
melhor: list[int] | None = None
|
||||||
|
melhor_dif = tolerancia + 1
|
||||||
|
nos = 0
|
||||||
|
caminho: list[int] = []
|
||||||
|
|
||||||
|
def dfs(inicio: int, soma: int) -> bool:
|
||||||
|
nonlocal melhor, melhor_dif, nos
|
||||||
|
nos += 1
|
||||||
|
if nos > MAX_NOS_BUSCA:
|
||||||
|
return True
|
||||||
|
if len(caminho) >= 2:
|
||||||
|
dif = abs(alvo - soma)
|
||||||
|
if dif < melhor_dif:
|
||||||
|
melhor, melhor_dif = list(caminho), dif
|
||||||
|
if dif == 0:
|
||||||
|
return True
|
||||||
|
if len(caminho) == max_itens:
|
||||||
|
return False
|
||||||
|
for pos in range(inicio, len(valores)):
|
||||||
|
nova = soma + valores[pos]
|
||||||
|
if nova > alvo + tolerancia:
|
||||||
|
continue
|
||||||
|
if soma + restante[pos] < alvo - tolerancia:
|
||||||
|
break
|
||||||
|
caminho.append(pos)
|
||||||
|
if dfs(pos + 1, nova):
|
||||||
|
return True
|
||||||
|
caminho.pop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
dfs(0, 0)
|
||||||
|
if melhor is None:
|
||||||
|
return None
|
||||||
|
return [candidatos[pos] for pos in melhor]
|
||||||
|
|
||||||
|
|
||||||
|
def _combina(
|
||||||
|
pendentes: list[ItemConciliacao],
|
||||||
|
tipo: str,
|
||||||
|
max_itens: int,
|
||||||
|
tolerancia: Tolerancia | None,
|
||||||
|
) -> list[VinculoSugerido]:
|
||||||
|
"""Para cada item ainda pendente (créditos primeiro, na ordem de
|
||||||
|
leitura), procura uma combinação de itens da natureza oposta."""
|
||||||
|
vinculos: list[VinculoSugerido] = []
|
||||||
|
usados: set[int] = set()
|
||||||
|
ordenados = sorted(pendentes, key=lambda i: (i.natureza != "C", i.indice))
|
||||||
|
for alvo in ordenados:
|
||||||
|
if alvo.indice in usados:
|
||||||
|
continue
|
||||||
|
limite = tolerancia.limite_centavos(alvo.centavos) if tolerancia else 0
|
||||||
|
candidatos = [
|
||||||
|
i
|
||||||
|
for i in pendentes
|
||||||
|
if i.natureza != alvo.natureza
|
||||||
|
and i.indice not in usados
|
||||||
|
and i.centavos <= alvo.centavos + limite
|
||||||
|
and _distancia(i, alvo) <= JANELA_DIAS_COMBINACAO
|
||||||
|
]
|
||||||
|
if not candidatos:
|
||||||
|
continue
|
||||||
|
candidatos.sort(key=lambda i: (_distancia(i, alvo), i.indice))
|
||||||
|
candidatos = candidatos[:MAX_CANDIDATOS_COMBINACAO]
|
||||||
|
# 1:1 com tolerância também conta (a busca exige 2 itens ou mais)
|
||||||
|
escolhido: list[ItemConciliacao] | None = None
|
||||||
|
if tolerancia:
|
||||||
|
proximos = [c for c in candidatos if abs(c.centavos - alvo.centavos) <= limite]
|
||||||
|
if proximos:
|
||||||
|
escolhido = [min(proximos, key=lambda c: (abs(c.centavos - alvo.centavos), _distancia(c, alvo)))]
|
||||||
|
if escolhido is None and len(candidatos) >= 2:
|
||||||
|
escolhido = _busca_combinacao(alvo.centavos, candidatos, max_itens, limite)
|
||||||
|
if not escolhido:
|
||||||
|
continue
|
||||||
|
grupo = [alvo, *escolhido]
|
||||||
|
usados.update(i.indice for i in grupo)
|
||||||
|
vinculos.append(_vinculo(tipo, grupo))
|
||||||
|
return vinculos
|
||||||
|
|
||||||
|
|
||||||
|
def _sem_vinculo(itens: list[ItemConciliacao], vinculos: list[VinculoSugerido]) -> list[ItemConciliacao]:
|
||||||
|
usados = {indice for v in vinculos for indice in v.indices}
|
||||||
|
return [i for i in itens if i.indice not in usados]
|
||||||
|
|
||||||
|
|
||||||
|
def concilia(itens: list[ItemConciliacao], tolerancia: Tolerancia) -> 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)."""
|
||||||
|
vinculos: list[VinculoSugerido] = []
|
||||||
|
segmentos = _segmentos(itens)
|
||||||
|
for posicao, segmento in enumerate(segmentos):
|
||||||
|
if not segmento:
|
||||||
|
continue
|
||||||
|
aberto = posicao == len(segmentos) - 1
|
||||||
|
locais = _pareia_exato(segmento)
|
||||||
|
locais += _combina(_sem_vinculo(segmento, locais), TIPO_COMBINACAO, MAX_ITENS_COMBINACAO, None)
|
||||||
|
restantes = _sem_vinculo(segmento, locais)
|
||||||
|
if not aberto:
|
||||||
|
if restantes:
|
||||||
|
locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes))
|
||||||
|
else:
|
||||||
|
locais += _combina(restantes, TIPO_TOLERANCIA, MAX_ITENS_TOLERANCIA, tolerancia)
|
||||||
|
vinculos += locais
|
||||||
|
return vinculos
|
||||||
272
portal_api/conciliacao_fornecedores/parser.py
Normal file
272
portal_api/conciliacao_fornecedores/parser.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
"""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:
|
||||||
|
|
||||||
|
Data, Seqüência, Histórico, Contrapartida, Descrição, Valor, Débito,
|
||||||
|
Crédito, Participante, Saldo, Filial, Usuário, Origem
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Python puro, sem ORM (mesmo espírito de `dashboard_contabil/parser.py`):
|
||||||
|
recebe o conteúdo do arquivo em bytes e devolve dataclasses.
|
||||||
|
|
||||||
|
Convenção de sinal: saldo **credor é positivo** (valor devido ao
|
||||||
|
fornecedor), devedor é negativo. O Questor imprime "16.429,84C"/"10,00D".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
_RE_CONTA = re.compile(r"^Conta:\s*(\d+)\s*-\s*([\d.]+)\s+(.*)$")
|
||||||
|
_RE_VALOR_DC = re.compile(r"^(-?[\d.]+,\d{2})\s*([DC])$")
|
||||||
|
|
||||||
|
|
||||||
|
class ArquivoRazaoInvalidoError(ValueError):
|
||||||
|
"""Arquivo que não segue o leiaute esperado da exportação do Questor."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LancamentoExtraido:
|
||||||
|
ordem: int
|
||||||
|
data: date
|
||||||
|
sequencia: str
|
||||||
|
historico: str
|
||||||
|
contrapartida_codigo: str
|
||||||
|
contrapartida_descricao: str
|
||||||
|
natureza: str # "D" ou "C"
|
||||||
|
valor: Decimal # sempre positivo
|
||||||
|
saldo_apos: Decimal | None # saldo impresso após o lançamento, credor positivo
|
||||||
|
filial: str
|
||||||
|
usuario: str
|
||||||
|
origem: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ContaExtraida:
|
||||||
|
ordem: int
|
||||||
|
conta_numero: str
|
||||||
|
classificacao: str
|
||||||
|
fornecedor: str
|
||||||
|
saldo_inicial: Decimal = Decimal("0")
|
||||||
|
data_saldo_inicial: date | None = None
|
||||||
|
lancamentos: list[LancamentoExtraido] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def saldo_final_calculado(self) -> Decimal:
|
||||||
|
total = self.saldo_inicial
|
||||||
|
for lanc in self.lancamentos:
|
||||||
|
total += lanc.valor if lanc.natureza == "C" else -lanc.valor
|
||||||
|
return total
|
||||||
|
|
||||||
|
@property
|
||||||
|
def saldo_final_arquivo(self) -> Decimal:
|
||||||
|
"""Último saldo impresso pelo Questor (o do saldo anterior, se a
|
||||||
|
conta não teve movimento)."""
|
||||||
|
for lanc in reversed(self.lancamentos):
|
||||||
|
if lanc.saldo_apos is not None:
|
||||||
|
return lanc.saldo_apos
|
||||||
|
return self.saldo_inicial
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RazaoExtraido:
|
||||||
|
contas: list[ContaExtraida]
|
||||||
|
periodo_inicio: date | None
|
||||||
|
periodo_fim: date | None
|
||||||
|
|
||||||
|
|
||||||
|
def _sem_acento(texto: str) -> str:
|
||||||
|
normalizado = unicodedata.normalize("NFKD", texto)
|
||||||
|
return "".join(c for c in normalizado if not unicodedata.combining(c)).upper().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _texto(valor: object) -> str:
|
||||||
|
if valor is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(valor, float) and valor.is_integer():
|
||||||
|
return str(int(valor))
|
||||||
|
return str(valor).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _decimal_br(texto: str) -> Decimal:
|
||||||
|
try:
|
||||||
|
return Decimal(texto.replace(".", "").replace(",", "."))
|
||||||
|
except InvalidOperation as exc:
|
||||||
|
raise ArquivoRazaoInvalidoError(f"Valor numérico inválido: {texto!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _valor_dc(valor: object) -> tuple[Decimal, str] | None:
|
||||||
|
""""8.214,92D" -> (Decimal("8214.92"), "D"). `None` para célula vazia."""
|
||||||
|
texto = _texto(valor)
|
||||||
|
if not texto:
|
||||||
|
return None
|
||||||
|
casamento = _RE_VALOR_DC.match(texto)
|
||||||
|
if not casamento:
|
||||||
|
raise ArquivoRazaoInvalidoError(f"Valor sem indicação de débito/crédito: {texto!r}")
|
||||||
|
return abs(_decimal_br(casamento.group(1))), casamento.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _saldo_assinado(valor: object) -> Decimal | None:
|
||||||
|
lido = _valor_dc(valor)
|
||||||
|
if lido is None:
|
||||||
|
return None
|
||||||
|
numero, natureza = lido
|
||||||
|
return numero if natureza == "C" else -numero
|
||||||
|
|
||||||
|
|
||||||
|
def _data(valor: object) -> date | None:
|
||||||
|
if valor is None or valor == "":
|
||||||
|
return None
|
||||||
|
if isinstance(valor, datetime):
|
||||||
|
return valor.date()
|
||||||
|
if isinstance(valor, date):
|
||||||
|
return valor
|
||||||
|
texto = _texto(valor)
|
||||||
|
try:
|
||||||
|
return datetime.strptime(texto, "%d/%m/%Y").date()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ArquivoRazaoInvalidoError(f"Data inválida: {texto!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _linhas_xlsx(conteudo: bytes) -> list[list[object]]:
|
||||||
|
wb = load_workbook(io.BytesIO(conteudo), read_only=True, data_only=True)
|
||||||
|
try:
|
||||||
|
ws = wb.worksheets[0]
|
||||||
|
return [list(linha) for linha in ws.iter_rows(values_only=True)]
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _linhas_csv(conteudo: bytes) -> list[list[object]]:
|
||||||
|
# A exportação do Questor sai em Latin-1; UTF-8 fica como tentativa
|
||||||
|
# primeiro para o caso de o arquivo ter sido salvo de novo por outro
|
||||||
|
# programa.
|
||||||
|
try:
|
||||||
|
texto = conteudo.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
texto = conteudo.decode("latin-1")
|
||||||
|
amostra = texto[:2000]
|
||||||
|
delimitador = ";" if amostra.count(";") > amostra.count(",") else ","
|
||||||
|
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 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:
|
||||||
|
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)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extrai_razao(conteudo: bytes, nome_arquivo: str) -> RazaoExtraido:
|
||||||
|
"""Lê o razão a partir do conteúdo do arquivo (.xlsx ou .csv)."""
|
||||||
|
extensao = nome_arquivo.lower().rsplit(".", 1)[-1] if "." in nome_arquivo else ""
|
||||||
|
if extensao == "xlsx":
|
||||||
|
linhas = _linhas_xlsx(conteudo)
|
||||||
|
elif extensao == "csv":
|
||||||
|
linhas = _linhas_csv(conteudo)
|
||||||
|
else:
|
||||||
|
raise ArquivoRazaoInvalidoError("Formato não suportado. Anexe a exportação em .xlsx ou .csv.")
|
||||||
|
|
||||||
|
if not linhas:
|
||||||
|
raise ArquivoRazaoInvalidoError("O arquivo está vazio.")
|
||||||
|
_confere_cabecalho(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):
|
||||||
|
continue
|
||||||
|
|
||||||
|
casamento_conta = _RE_CONTA.match(historico)
|
||||||
|
if casamento_conta:
|
||||||
|
atual = ContaExtraida(
|
||||||
|
ordem=len(contas),
|
||||||
|
conta_numero=casamento_conta.group(1),
|
||||||
|
classificacao=casamento_conta.group(2),
|
||||||
|
fornecedor=casamento_conta.group(3).strip(),
|
||||||
|
)
|
||||||
|
contas.append(atual)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if atual is None:
|
||||||
|
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])
|
||||||
|
continue
|
||||||
|
|
||||||
|
data_lanc = _data(celulas[0])
|
||||||
|
valor = _valor_dc(celulas[5])
|
||||||
|
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
|
||||||
|
atual.lancamentos.append(
|
||||||
|
LancamentoExtraido(
|
||||||
|
ordem=ordem_lancamento,
|
||||||
|
data=data_lanc,
|
||||||
|
sequencia=_texto(celulas[1]),
|
||||||
|
historico=historico,
|
||||||
|
contrapartida_codigo=_texto(celulas[3]),
|
||||||
|
contrapartida_descricao=_texto(celulas[4]),
|
||||||
|
natureza=natureza,
|
||||||
|
valor=numero,
|
||||||
|
saldo_apos=_saldo_assinado(celulas[9]),
|
||||||
|
filial=_texto(celulas[10]),
|
||||||
|
usuario=_texto(celulas[11]),
|
||||||
|
origem=_texto(celulas[12]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ordem_lancamento += 1
|
||||||
|
|
||||||
|
if not contas:
|
||||||
|
raise ArquivoRazaoInvalidoError("Nenhuma conta encontrada no arquivo (linhas \"Conta: ...\").")
|
||||||
|
|
||||||
|
datas = [lanc.data for conta in contas for lanc in conta.lancamentos]
|
||||||
|
return RazaoExtraido(
|
||||||
|
contas=contas,
|
||||||
|
periodo_inicio=min(datas) if datas else None,
|
||||||
|
periodo_fim=max(datas) if datas else None,
|
||||||
|
)
|
||||||
@ -192,6 +192,10 @@ class Command(BaseCommand):
|
|||||||
# Contratação, Indicador de Desempenho), que continuam com a
|
# Contratação, Indicador de Desempenho), que continuam com a
|
||||||
# visibilidade ampla de sempre.
|
# visibilidade ampla de sempre.
|
||||||
permissoes["utilitarios"]["apps"]["importacao-plano-saude-de-paula"] = False
|
permissoes["utilitarios"]["apps"]["importacao-plano-saude-de-paula"] = False
|
||||||
|
# "Conciliação de Fornecedores" nasce restrita a Inovação por
|
||||||
|
# decisão explícita do usuário (dado de cliente, mas ferramenta
|
||||||
|
# ainda em validação), liberada depois pela tela.
|
||||||
|
permissoes["utilitarios"]["apps"]["conciliacao-fornecedores"] = False
|
||||||
# `nome` só é gravado na criação — reexecuções não devem reverter uma
|
# `nome` só é gravado na criação — reexecuções não devem reverter uma
|
||||||
# renomeação feita depois pela própria tela de Perfis de Acesso (mesmo
|
# renomeação feita depois pela própria tela de Perfis de Acesso (mesmo
|
||||||
# espírito de "só inicializa na primeira criação" já usado pra
|
# espírito de "só inicializa na primeira criação" já usado pra
|
||||||
|
|||||||
103
portal_api/migrations/0082_conciliacao_fornecedores.py
Normal file
103
portal_api/migrations/0082_conciliacao_fornecedores.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-09-24 13:57
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import portal_api.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('portal_api', '0081_contabil_titulo_variacao_analise_vertical'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConciliacaoFornecedor',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('codigo_empresa', models.CharField(max_length=20, verbose_name='Código da empresa')),
|
||||||
|
('nome_empresa', models.CharField(blank=True, max_length=255, verbose_name='Nome da empresa')),
|
||||||
|
('data_base', models.DateField(verbose_name='Data-base da análise')),
|
||||||
|
('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')),
|
||||||
|
('arquivo', models.FileField(max_length=255, upload_to='conciliacao_fornecedores/', validators=[portal_api.models.validar_tamanho_arquivo_conciliacao], verbose_name='Arquivo do razão')),
|
||||||
|
('nome_arquivo', models.CharField(blank=True, max_length=255, verbose_name='Nome do arquivo original')),
|
||||||
|
('tolerancia_valor', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Tolerância (R$)')),
|
||||||
|
('tolerancia_percentual', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='Tolerância (%)')),
|
||||||
|
('resumo', models.JSONField(blank=True, default=dict, verbose_name='Resumo')),
|
||||||
|
('criado_em', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('criado_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conciliacoes_fornecedores', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Conciliação de fornecedores',
|
||||||
|
'verbose_name_plural': 'Conciliações de fornecedores',
|
||||||
|
'ordering': ['-criado_em', '-id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConciliacaoFornecedorConta',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('ordem', models.PositiveIntegerField(verbose_name='Ordem no arquivo')),
|
||||||
|
('conta_numero', models.CharField(max_length=20, verbose_name='Conta (reduzido)')),
|
||||||
|
('classificacao', models.CharField(max_length=40, verbose_name='Classificação')),
|
||||||
|
('fornecedor', models.CharField(max_length=255, verbose_name='Fornecedor')),
|
||||||
|
('saldo_inicial', models.DecimalField(decimal_places=2, max_digits=16, verbose_name='Saldo inicial')),
|
||||||
|
('saldo_final', models.DecimalField(decimal_places=2, max_digits=16, verbose_name='Saldo final (recalculado)')),
|
||||||
|
('saldo_final_arquivo', models.DecimalField(decimal_places=2, max_digits=16, verbose_name='Saldo final (arquivo)')),
|
||||||
|
('validada', models.BooleanField(default=False, verbose_name='Validada pelo contador')),
|
||||||
|
('validada_em', models.DateTimeField(blank=True, null=True, verbose_name='Validada em')),
|
||||||
|
('observacao', models.TextField(blank=True, verbose_name='Observação do contador')),
|
||||||
|
('conciliacao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contas', to='portal_api.conciliacaofornecedor')),
|
||||||
|
('validada_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Conta (Conciliação de fornecedores)',
|
||||||
|
'verbose_name_plural': 'Contas (Conciliação de fornecedores)',
|
||||||
|
'ordering': ['ordem', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConciliacaoFornecedorVinculo',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('tipo', models.CharField(choices=[('exato', 'Valor exato'), ('combinacao', 'Combinação de valores'), ('saldo_zerado', 'Compensação até saldo zerado'), ('tolerancia', 'Com diferença (juros/desconto)'), ('manual', 'Manual')], max_length=20, verbose_name='Tipo')),
|
||||||
|
('diferenca', models.DecimalField(decimal_places=2, default=0, max_digits=16, verbose_name='Diferença')),
|
||||||
|
('criado_em', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('conta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vinculos', to='portal_api.conciliacaofornecedorconta')),
|
||||||
|
('criado_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Vínculo (Conciliação de fornecedores)',
|
||||||
|
'verbose_name_plural': 'Vínculos (Conciliação de fornecedores)',
|
||||||
|
'ordering': ['id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConciliacaoFornecedorLancamento',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('ordem', models.PositiveIntegerField(verbose_name='Ordem na conta')),
|
||||||
|
('eh_saldo_anterior', models.BooleanField(default=False, verbose_name='Saldo anterior')),
|
||||||
|
('data', models.DateField(verbose_name='Data')),
|
||||||
|
('sequencia', models.CharField(blank=True, max_length=20, verbose_name='Seqüência')),
|
||||||
|
('historico', models.CharField(blank=True, max_length=500, verbose_name='Histórico')),
|
||||||
|
('contrapartida_codigo', models.CharField(blank=True, max_length=20, verbose_name='Contrapartida')),
|
||||||
|
('contrapartida_descricao', models.CharField(blank=True, max_length=255, verbose_name='Descrição da contrapartida')),
|
||||||
|
('natureza', models.CharField(choices=[('D', 'Débito'), ('C', 'Crédito')], max_length=1, verbose_name='Natureza')),
|
||||||
|
('valor', models.DecimalField(decimal_places=2, max_digits=16, verbose_name='Valor')),
|
||||||
|
('saldo_apos', models.DecimalField(blank=True, decimal_places=2, max_digits=16, null=True, verbose_name='Saldo após o lançamento')),
|
||||||
|
('usuario', models.CharField(blank=True, max_length=100, verbose_name='Usuário (Questor)')),
|
||||||
|
('origem', models.CharField(blank=True, max_length=10, verbose_name='Origem')),
|
||||||
|
('conta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lancamentos', to='portal_api.conciliacaofornecedorconta')),
|
||||||
|
('vinculo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lancamentos', to='portal_api.conciliacaofornecedorvinculo')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Lançamento (Conciliação de fornecedores)',
|
||||||
|
'verbose_name_plural': 'Lançamentos (Conciliação de fornecedores)',
|
||||||
|
'ordering': ['ordem', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
portal_api/migrations/0083_conciliacao_vinculo_parcial.py
Normal file
18
portal_api/migrations/0083_conciliacao_vinculo_parcial.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-09-24 18:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('portal_api', '0082_conciliacao_fornecedores'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='conciliacaofornecedorvinculo',
|
||||||
|
name='tipo',
|
||||||
|
field=models.CharField(choices=[('exato', 'Valor exato'), ('combinacao', 'Combinação de valores'), ('saldo_zerado', 'Compensação até saldo zerado'), ('tolerancia', 'Com diferença (juros/desconto)'), ('manual', 'Manual'), ('parcial', 'Pagamento parcial')], max_length=20, verbose_name='Tipo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -2677,3 +2677,148 @@ class IndicadorContabilComponente(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.chave} ({self.get_tipo_display()})"
|
return f"{self.chave} ({self.get_tipo_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Conciliação de Fornecedores (Utilitários) -----------------------------
|
||||||
|
# Ver portal_api/conciliacao_fornecedores/CLAUDE.md. Cabeçalho → contas →
|
||||||
|
# lançamentos, com os vínculos (automáticos ou manuais) como registro
|
||||||
|
# próprio. Status/alertas de cada conta não são gravados: são recalculados a
|
||||||
|
# cada exibição (`conciliacao_fornecedores.alertas`), já que dependem dos
|
||||||
|
# vínculos atuais, que o contador pode desfazer ou criar.
|
||||||
|
|
||||||
|
CONCILIACAO_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def validar_tamanho_arquivo_conciliacao(arquivo: File) -> None:
|
||||||
|
if arquivo.size > CONCILIACAO_ARQUIVO_MAX_BYTES:
|
||||||
|
raise ValidationError("O arquivo deve ter no máximo 15MB.")
|
||||||
|
|
||||||
|
|
||||||
|
class ConciliacaoFornecedor(models.Model):
|
||||||
|
"""Uma conciliação do razão de fornecedores de uma empresa: o contador
|
||||||
|
informa o código da empresa (nome resolvido no Questor) e anexa a
|
||||||
|
exportação do razão (.xlsx ou .csv). Sem `unique_together`: a mesma
|
||||||
|
empresa pode ser conciliada várias vezes (meses diferentes, ou de novo
|
||||||
|
depois de corrigir lançamentos no Questor)."""
|
||||||
|
|
||||||
|
codigo_empresa = models.CharField("Código da empresa", max_length=20)
|
||||||
|
nome_empresa = models.CharField("Nome da empresa", max_length=255, blank=True)
|
||||||
|
data_base = models.DateField("Data-base da análise")
|
||||||
|
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)
|
||||||
|
arquivo = models.FileField(
|
||||||
|
"Arquivo do razão",
|
||||||
|
upload_to="conciliacao_fornecedores/",
|
||||||
|
max_length=255,
|
||||||
|
validators=[validar_tamanho_arquivo_conciliacao],
|
||||||
|
)
|
||||||
|
nome_arquivo = models.CharField("Nome do arquivo original", max_length=255, blank=True)
|
||||||
|
tolerancia_valor = models.DecimalField("Tolerância (R$)", max_digits=12, decimal_places=2)
|
||||||
|
tolerancia_percentual = models.DecimalField("Tolerância (%)", max_digits=5, decimal_places=2)
|
||||||
|
# Totais por status/valores, recalculados a cada criação e a cada ajuste
|
||||||
|
# manual (`_conciliacao_atualiza_resumo()` em views.py). Só existe para a
|
||||||
|
# listagem do histórico não precisar reanalisar todas as contas de
|
||||||
|
# todas as conciliações; a tela de detalhe usa a análise fresca.
|
||||||
|
resumo = models.JSONField("Resumo", default=dict, blank=True)
|
||||||
|
criado_por = models.ForeignKey(
|
||||||
|
Usuario, on_delete=models.SET_NULL, null=True, related_name="conciliacoes_fornecedores"
|
||||||
|
)
|
||||||
|
criado_em = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Conciliação de fornecedores"
|
||||||
|
verbose_name_plural = "Conciliações de fornecedores"
|
||||||
|
ordering = ["-criado_em", "-id"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.codigo_empresa} - {self.nome_empresa} ({self.data_base:%d/%m/%Y})"
|
||||||
|
|
||||||
|
|
||||||
|
class ConciliacaoFornecedorConta(models.Model):
|
||||||
|
"""Uma conta de fornecedor do razão. `saldo_inicial`/`saldo_final`
|
||||||
|
seguem a convenção credor positivo (valor devido ao fornecedor)."""
|
||||||
|
|
||||||
|
conciliacao = models.ForeignKey(ConciliacaoFornecedor, on_delete=models.CASCADE, related_name="contas")
|
||||||
|
ordem = models.PositiveIntegerField("Ordem no arquivo")
|
||||||
|
conta_numero = models.CharField("Conta (reduzido)", max_length=20)
|
||||||
|
classificacao = models.CharField("Classificação", max_length=40)
|
||||||
|
fornecedor = models.CharField("Fornecedor", max_length=255)
|
||||||
|
saldo_inicial = models.DecimalField("Saldo inicial", max_digits=16, decimal_places=2)
|
||||||
|
saldo_final = models.DecimalField("Saldo final (recalculado)", max_digits=16, decimal_places=2)
|
||||||
|
saldo_final_arquivo = models.DecimalField("Saldo final (arquivo)", max_digits=16, decimal_places=2)
|
||||||
|
validada = models.BooleanField("Validada pelo contador", default=False)
|
||||||
|
validada_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
|
||||||
|
validada_em = models.DateTimeField("Validada em", null=True, blank=True)
|
||||||
|
observacao = models.TextField("Observação do contador", blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Conta (Conciliação de fornecedores)"
|
||||||
|
verbose_name_plural = "Contas (Conciliação de fornecedores)"
|
||||||
|
ordering = ["ordem", "id"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.conta_numero} - {self.fornecedor}"
|
||||||
|
|
||||||
|
|
||||||
|
class ConciliacaoFornecedorVinculo(models.Model):
|
||||||
|
"""Um conjunto de lançamentos da mesma conta que se compensam.
|
||||||
|
`diferenca` = soma dos créditos - soma dos débitos (zero nos vínculos
|
||||||
|
exatos). Excluir um vínculo devolve os lançamentos para pendente
|
||||||
|
(`SET_NULL` em `ConciliacaoFornecedorLancamento.vinculo`)."""
|
||||||
|
|
||||||
|
TIPO_CHOICES = [
|
||||||
|
("exato", "Valor exato"),
|
||||||
|
("combinacao", "Combinação de valores"),
|
||||||
|
("saldo_zerado", "Compensação até saldo zerado"),
|
||||||
|
("tolerancia", "Com diferença (juros/desconto)"),
|
||||||
|
("manual", "Manual"),
|
||||||
|
("parcial", "Pagamento parcial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
conta = models.ForeignKey(ConciliacaoFornecedorConta, on_delete=models.CASCADE, related_name="vinculos")
|
||||||
|
tipo = models.CharField("Tipo", max_length=20, choices=TIPO_CHOICES)
|
||||||
|
diferenca = models.DecimalField("Diferença", max_digits=16, decimal_places=2, default=0)
|
||||||
|
criado_por = 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 = "Vínculo (Conciliação de fornecedores)"
|
||||||
|
verbose_name_plural = "Vínculos (Conciliação de fornecedores)"
|
||||||
|
ordering = ["id"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.get_tipo_display()} #{self.pk}"
|
||||||
|
|
||||||
|
|
||||||
|
class ConciliacaoFornecedorLancamento(models.Model):
|
||||||
|
"""Um lançamento do razão, ou o saldo anterior da conta
|
||||||
|
(`eh_saldo_anterior`, só quando diferente de zero — entra na
|
||||||
|
conciliação como um título/pagamento a mais, sem detalhe de
|
||||||
|
composição). `valor` sempre positivo; `natureza` diz o lado."""
|
||||||
|
|
||||||
|
NATUREZA_CHOICES = [("D", "Débito"), ("C", "Crédito")]
|
||||||
|
|
||||||
|
conta = models.ForeignKey(ConciliacaoFornecedorConta, on_delete=models.CASCADE, related_name="lancamentos")
|
||||||
|
ordem = models.PositiveIntegerField("Ordem na conta")
|
||||||
|
eh_saldo_anterior = models.BooleanField("Saldo anterior", default=False)
|
||||||
|
data = models.DateField("Data")
|
||||||
|
sequencia = models.CharField("Seqüência", max_length=20, blank=True)
|
||||||
|
historico = models.CharField("Histórico", max_length=500, blank=True)
|
||||||
|
contrapartida_codigo = models.CharField("Contrapartida", max_length=20, blank=True)
|
||||||
|
contrapartida_descricao = models.CharField("Descrição da contrapartida", max_length=255, blank=True)
|
||||||
|
natureza = models.CharField("Natureza", max_length=1, choices=NATUREZA_CHOICES)
|
||||||
|
valor = models.DecimalField("Valor", max_digits=16, decimal_places=2)
|
||||||
|
saldo_apos = models.DecimalField("Saldo após o lançamento", max_digits=16, decimal_places=2, null=True, blank=True)
|
||||||
|
usuario = models.CharField("Usuário (Questor)", max_length=100, blank=True)
|
||||||
|
origem = models.CharField("Origem", max_length=10, blank=True)
|
||||||
|
vinculo = models.ForeignKey(
|
||||||
|
ConciliacaoFornecedorVinculo, on_delete=models.SET_NULL, null=True, blank=True, related_name="lancamentos"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Lançamento (Conciliação de fornecedores)"
|
||||||
|
verbose_name_plural = "Lançamentos (Conciliação de fornecedores)"
|
||||||
|
ordering = ["ordem", "id"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.data:%d/%m/%Y} {self.natureza} {self.valor}"
|
||||||
|
|||||||
@ -11,11 +11,13 @@ from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
|
|||||||
from .indicadores import calculo as indicadores_calculo
|
from .indicadores import calculo as indicadores_calculo
|
||||||
from .nao_conformidades import classificacao as nc_classificacao
|
from .nao_conformidades import classificacao as nc_classificacao
|
||||||
from .models import (
|
from .models import (
|
||||||
|
CONCILIACAO_ARQUIVO_MAX_BYTES,
|
||||||
AcessoGeral,
|
AcessoGeral,
|
||||||
AcessoGeralSecao,
|
AcessoGeralSecao,
|
||||||
AjudaAplicacao,
|
AjudaAplicacao,
|
||||||
CategoriaEvento,
|
CategoriaEvento,
|
||||||
CompromissoAgenda,
|
CompromissoAgenda,
|
||||||
|
ConciliacaoFornecedor,
|
||||||
ContabilAchado,
|
ContabilAchado,
|
||||||
ContabilApuracao,
|
ContabilApuracao,
|
||||||
ContabilApuracaoReprocessamento,
|
ContabilApuracaoReprocessamento,
|
||||||
@ -2782,3 +2784,62 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
|
|||||||
"linhas_analise_vertical",
|
"linhas_analise_vertical",
|
||||||
"achados",
|
"achados",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Conciliação de Fornecedores (Utilitários) -----------------------------
|
||||||
|
# O detalhe (contas + lançamentos + análise recalculada) é montado em
|
||||||
|
# `_conciliacao_detalhe()` em views.py, porque depende da análise de
|
||||||
|
# `conciliacao_fornecedores.alertas` (não é campo de model).
|
||||||
|
|
||||||
|
|
||||||
|
class ConciliacaoFornecedorListSerializer(serializers.ModelSerializer):
|
||||||
|
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConciliacaoFornecedor
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"codigo_empresa",
|
||||||
|
"nome_empresa",
|
||||||
|
"data_base",
|
||||||
|
"periodo_inicio",
|
||||||
|
"periodo_fim",
|
||||||
|
"nome_arquivo",
|
||||||
|
"tolerancia_valor",
|
||||||
|
"tolerancia_percentual",
|
||||||
|
"resumo",
|
||||||
|
"criado_por_nome",
|
||||||
|
"criado_em",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
def validate_codigo_empresa(self, valor: str) -> str:
|
||||||
|
codigo = normalizar_codigo_empresa(valor)
|
||||||
|
if not codigo.isdigit():
|
||||||
|
raise serializers.ValidationError("Informe o código numérico da empresa.")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class ConciliacaoFornecedorVincularSerializer(serializers.Serializer):
|
||||||
|
# 2+ para criar um vínculo; 1 já basta para acrescentar a um existente
|
||||||
|
# (a exigência de débito + crédito da criação fica na view).
|
||||||
|
lancamentos = serializers.ListField(child=serializers.IntegerField(), min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ConciliacaoFornecedorValidarSerializer(serializers.Serializer):
|
||||||
|
validada = serializers.BooleanField()
|
||||||
|
observacao = serializers.CharField(required=False, allow_blank=True, max_length=5000)
|
||||||
|
|||||||
@ -111,6 +111,20 @@ router.register(
|
|||||||
basename="contabil-indicador-definicao",
|
basename="contabil-indicador-definicao",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.register(
|
||||||
|
"conciliacoes-fornecedores", views.ConciliacaoFornecedorViewSet, basename="conciliacao-fornecedor"
|
||||||
|
)
|
||||||
|
router.register(
|
||||||
|
"conciliacoes-fornecedores-contas",
|
||||||
|
views.ConciliacaoFornecedorContaViewSet,
|
||||||
|
basename="conciliacao-fornecedor-conta",
|
||||||
|
)
|
||||||
|
router.register(
|
||||||
|
"conciliacoes-fornecedores-vinculos",
|
||||||
|
views.ConciliacaoFornecedorVinculoViewSet,
|
||||||
|
basename="conciliacao-fornecedor-vinculo",
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("auth/csrf/", views.csrf_view, name="auth-csrf"),
|
path("auth/csrf/", views.csrf_view, name="auth-csrf"),
|
||||||
path("auth/login/", views.login_view, name="auth-login"),
|
path("auth/login/", views.login_view, name="auth-login"),
|
||||||
|
|||||||
@ -36,6 +36,10 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.serializers import ModelSerializer
|
from rest_framework.serializers import ModelSerializer
|
||||||
|
|
||||||
from . import catalogo
|
from . import catalogo
|
||||||
|
from .conciliacao_fornecedores import alertas as conciliacao_alertas
|
||||||
|
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 .custo_contratacao.calculo import calcula_custo_empregado
|
from .custo_contratacao.calculo import calcula_custo_empregado
|
||||||
from .custo_contratacao.pdf import gera_pdf_simulacao
|
from .custo_contratacao.pdf import gera_pdf_simulacao
|
||||||
from .dashboard_contabil import chaves as dashboard_contabil_chaves
|
from .dashboard_contabil import chaves as dashboard_contabil_chaves
|
||||||
@ -57,6 +61,10 @@ from .models import (
|
|||||||
AjudaAplicacao,
|
AjudaAplicacao,
|
||||||
CategoriaEvento,
|
CategoriaEvento,
|
||||||
CompromissoAgenda,
|
CompromissoAgenda,
|
||||||
|
ConciliacaoFornecedor,
|
||||||
|
ConciliacaoFornecedorConta,
|
||||||
|
ConciliacaoFornecedorLancamento,
|
||||||
|
ConciliacaoFornecedorVinculo,
|
||||||
ContabilAchado,
|
ContabilAchado,
|
||||||
ContabilApuracao,
|
ContabilApuracao,
|
||||||
ContabilApuracaoReprocessamento,
|
ContabilApuracaoReprocessamento,
|
||||||
@ -132,6 +140,10 @@ from .serializers import (
|
|||||||
AjudaAplicacaoSerializer,
|
AjudaAplicacaoSerializer,
|
||||||
CategoriaEventoSerializer,
|
CategoriaEventoSerializer,
|
||||||
CompromissoAgendaSerializer,
|
CompromissoAgendaSerializer,
|
||||||
|
ConciliacaoFornecedorCreateSerializer,
|
||||||
|
ConciliacaoFornecedorListSerializer,
|
||||||
|
ConciliacaoFornecedorValidarSerializer,
|
||||||
|
ConciliacaoFornecedorVincularSerializer,
|
||||||
ContabilAchadoAjusteSerializer,
|
ContabilAchadoAjusteSerializer,
|
||||||
ContabilAchadoOcultoSerializer,
|
ContabilAchadoOcultoSerializer,
|
||||||
ContabilAchadoValidarSerializer,
|
ContabilAchadoValidarSerializer,
|
||||||
@ -5229,3 +5241,607 @@ class IndicadorContabilDefinicaoViewSet(viewsets.ModelViewSet):
|
|||||||
campos_alterados.append("indicadores_ocultos")
|
campos_alterados.append("indicadores_ocultos")
|
||||||
if campos_alterados:
|
if campos_alterados:
|
||||||
apuracao.save(update_fields=campos_alterados)
|
apuracao.save(update_fields=campos_alterados)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Conciliação de Fornecedores (Utilitários) -----------------------------
|
||||||
|
# Ver portal_api/conciliacao_fornecedores/CLAUDE.md. Os vínculos
|
||||||
|
# (automáticos na criação, manuais depois) são gravados; status/alertas de
|
||||||
|
# cada conta são recalculados a cada leitura por
|
||||||
|
# `conciliacao_fornecedores.alertas.analisa_conta()`.
|
||||||
|
|
||||||
|
_CONCILIACAO_PERMISSAO = ("utilitarios", "conciliacao-fornecedores")
|
||||||
|
_CONCILIACAO_ROTULO_STATUS = {
|
||||||
|
conciliacao_alertas.STATUS_CONCILIADA: "Conciliada",
|
||||||
|
conciliacao_alertas.STATUS_PENDENTE: "Pendente de pagamento",
|
||||||
|
conciliacao_alertas.STATUS_ATENCAO: "Atenção",
|
||||||
|
conciliacao_alertas.STATUS_DIVERGENTE: "Divergente",
|
||||||
|
}
|
||||||
|
_CONCILIACAO_ROTULO_SEVERIDADE = {"alta": "Alta", "media": "Média", "baixa": "Baixa"}
|
||||||
|
|
||||||
|
_ConciliacaoContaCarregada = tuple[
|
||||||
|
ConciliacaoFornecedorConta,
|
||||||
|
list[ConciliacaoFornecedorLancamento],
|
||||||
|
list[ConciliacaoFornecedorVinculo],
|
||||||
|
conciliacao_alertas.AnaliseConta,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_rotulo_contrapartida(lanc: ConciliacaoFornecedorLancamento) -> str:
|
||||||
|
return " - ".join(p for p in (lanc.contrapartida_codigo, lanc.contrapartida_descricao.strip()) if p)
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_analisa(
|
||||||
|
conciliacao: ConciliacaoFornecedor,
|
||||||
|
conta: ConciliacaoFornecedorConta,
|
||||||
|
lancamentos: list[ConciliacaoFornecedorLancamento],
|
||||||
|
vinculos: list[ConciliacaoFornecedorVinculo],
|
||||||
|
) -> conciliacao_alertas.AnaliseConta:
|
||||||
|
return conciliacao_alertas.analisa_conta(
|
||||||
|
[
|
||||||
|
conciliacao_alertas.ItemAnalise(
|
||||||
|
id=lanc.id,
|
||||||
|
data=lanc.data,
|
||||||
|
natureza=lanc.natureza,
|
||||||
|
valor=lanc.valor,
|
||||||
|
vinculo_id=lanc.vinculo_id,
|
||||||
|
descricao=lanc.historico,
|
||||||
|
)
|
||||||
|
for lanc in lancamentos
|
||||||
|
],
|
||||||
|
[
|
||||||
|
conciliacao_alertas.VinculoAnalise(
|
||||||
|
id=v.id,
|
||||||
|
tipo=v.tipo,
|
||||||
|
diferenca=v.diferenca,
|
||||||
|
data_titulo=min((lanc.data for lanc in lancamentos if lanc.vinculo_id == v.id and lanc.natureza == "C"), default=None),
|
||||||
|
)
|
||||||
|
for v in vinculos
|
||||||
|
],
|
||||||
|
nome_conta=conta.fornecedor,
|
||||||
|
saldo_final=conta.saldo_final,
|
||||||
|
saldo_final_arquivo=conta.saldo_final_arquivo,
|
||||||
|
data_base=conciliacao.data_base,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_sincroniza_validacao_automatica(
|
||||||
|
conta: ConciliacaoFornecedorConta, analise: conciliacao_alertas.AnaliseConta
|
||||||
|
) -> bool:
|
||||||
|
"""Conta com situação "Conciliada" já nasce validada (pedido do usuário).
|
||||||
|
A validação automática é a que tem `validada_por` nulo: se a conta deixa
|
||||||
|
de estar conciliada (ex.: um vínculo desfeito), ela é retirada; a
|
||||||
|
validação feita por uma pessoa nunca é mexida aqui. Devolve se mudou
|
||||||
|
(sem salvar)."""
|
||||||
|
conciliada = analise.status == conciliacao_alertas.STATUS_CONCILIADA
|
||||||
|
if conciliada and not conta.validada:
|
||||||
|
conta.validada, conta.validada_por, conta.validada_em = True, None, timezone.now()
|
||||||
|
return True
|
||||||
|
if not conciliada and conta.validada and conta.validada_por_id is None:
|
||||||
|
conta.validada, conta.validada_em = False, None
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_conta_payload(
|
||||||
|
conta: ConciliacaoFornecedorConta,
|
||||||
|
lancamentos: list[ConciliacaoFornecedorLancamento],
|
||||||
|
vinculos: list[ConciliacaoFornecedorVinculo],
|
||||||
|
analise: conciliacao_alertas.AnaliseConta,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
def _situacao_saldo_parcial(vinculo: ConciliacaoFornecedorVinculo) -> dict[str, Any] | None:
|
||||||
|
item = analise.situacoes.get(-vinculo.id)
|
||||||
|
if vinculo.tipo != conciliacao_motor.TIPO_PARCIAL or item is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"categoria": item.categoria,
|
||||||
|
"valor_em_aberto": str(item.valor_em_aberto),
|
||||||
|
"descricao": item.descricao,
|
||||||
|
"sugestao": item.sugestao,
|
||||||
|
}
|
||||||
|
|
||||||
|
def situacao(lanc: ConciliacaoFornecedorLancamento) -> dict[str, Any] | None:
|
||||||
|
item = analise.situacoes.get(lanc.id)
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"categoria": item.categoria,
|
||||||
|
"valor_em_aberto": str(item.valor_em_aberto),
|
||||||
|
"descricao": item.descricao,
|
||||||
|
"sugestao": item.sugestao,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": conta.id,
|
||||||
|
"conta_numero": conta.conta_numero,
|
||||||
|
"classificacao": conta.classificacao,
|
||||||
|
"fornecedor": conta.fornecedor,
|
||||||
|
"saldo_inicial": str(conta.saldo_inicial),
|
||||||
|
"saldo_final": str(conta.saldo_final),
|
||||||
|
"validada": conta.validada,
|
||||||
|
"validada_por_nome": conta.validada_por.nome if conta.validada_por else None,
|
||||||
|
"validada_automatica": conta.validada and conta.validada_por_id is None,
|
||||||
|
"validada_em": conta.validada_em.isoformat() if conta.validada_em else None,
|
||||||
|
"observacao": conta.observacao,
|
||||||
|
"status": analise.status,
|
||||||
|
"status_rotulo": _CONCILIACAO_ROTULO_STATUS[analise.status],
|
||||||
|
"transitoria": conciliacao_alertas.eh_conta_transitoria(conta.fornecedor),
|
||||||
|
"alertas": [dataclasses.asdict(a) for a in analise.alertas],
|
||||||
|
"total_em_aberto": str(analise.total_em_aberto),
|
||||||
|
"total_vencido": str(analise.total_vencido),
|
||||||
|
"total_excedente": str(analise.total_excedente),
|
||||||
|
"total_nao_vinculado": str(analise.total_nao_vinculado),
|
||||||
|
"qtd_pendentes": sum(1 for lanc in lancamentos if lanc.vinculo_id is None),
|
||||||
|
"vinculos": [
|
||||||
|
{
|
||||||
|
"id": v.id,
|
||||||
|
"tipo": v.tipo,
|
||||||
|
"tipo_rotulo": v.get_tipo_display(),
|
||||||
|
"diferenca": str(v.diferenca),
|
||||||
|
"criado_por_nome": v.criado_por.nome if v.criado_por else None,
|
||||||
|
# pagamento parcial: saldo que continua em aberto
|
||||||
|
"situacao": _situacao_saldo_parcial(v),
|
||||||
|
}
|
||||||
|
for v in vinculos
|
||||||
|
],
|
||||||
|
"lancamentos": [
|
||||||
|
{
|
||||||
|
"id": lanc.id,
|
||||||
|
"data": lanc.data.isoformat(),
|
||||||
|
"eh_saldo_anterior": lanc.eh_saldo_anterior,
|
||||||
|
"sequencia": lanc.sequencia,
|
||||||
|
"historico": lanc.historico,
|
||||||
|
"contrapartida": _conciliacao_rotulo_contrapartida(lanc),
|
||||||
|
"natureza": lanc.natureza,
|
||||||
|
"valor": str(lanc.valor),
|
||||||
|
"saldo_apos": str(lanc.saldo_apos) if lanc.saldo_apos is not None else None,
|
||||||
|
"origem": lanc.origem,
|
||||||
|
"usuario": lanc.usuario,
|
||||||
|
"vinculo_id": lanc.vinculo_id,
|
||||||
|
"situacao": situacao(lanc),
|
||||||
|
}
|
||||||
|
for lanc in lancamentos
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_carrega(
|
||||||
|
conciliacao: ConciliacaoFornecedor, contas: list[ConciliacaoFornecedorConta] | None = None
|
||||||
|
) -> list[_ConciliacaoContaCarregada]:
|
||||||
|
"""Contas + lançamentos + vínculos + análise, com 3 consultas no total
|
||||||
|
(não uma por conta)."""
|
||||||
|
if contas is None:
|
||||||
|
contas = list(conciliacao.contas.select_related("validada_por").order_by("ordem", "id"))
|
||||||
|
ids = [c.id for c in contas]
|
||||||
|
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).select_related("criado_por"):
|
||||||
|
vinc_por_conta[vinc.conta_id].append(vinc)
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
conta,
|
||||||
|
lancs_por_conta[conta.id],
|
||||||
|
vinc_por_conta[conta.id],
|
||||||
|
_conciliacao_analisa(conciliacao, conta, lancs_por_conta[conta.id], vinc_por_conta[conta.id]),
|
||||||
|
)
|
||||||
|
for conta in contas
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_calcula_resumo(carregado: list[_ConciliacaoContaCarregada]) -> dict[str, Any]:
|
||||||
|
por_status = {chave: 0 for chave in _CONCILIACAO_ROTULO_STATUS}
|
||||||
|
total_aberto = total_vencido = total_excedente = total_nao_vinculado = Decimal("0")
|
||||||
|
validadas = 0
|
||||||
|
contas_por_alerta = {"credito_vencido": 0, "diferenca_vinculo": 0, "pagamento_sem_titulo": 0, "conta_transitoria": 0}
|
||||||
|
for conta, _, _, analise in carregado:
|
||||||
|
por_status[analise.status] += 1
|
||||||
|
validadas += conta.validada
|
||||||
|
total_aberto += analise.total_em_aberto
|
||||||
|
total_vencido += analise.total_vencido
|
||||||
|
total_excedente += analise.total_excedente
|
||||||
|
total_nao_vinculado += analise.total_nao_vinculado
|
||||||
|
for tipo in {a.tipo for a in analise.alertas}:
|
||||||
|
if tipo in contas_por_alerta:
|
||||||
|
contas_por_alerta[tipo] += 1
|
||||||
|
return {
|
||||||
|
"total_contas": len(carregado),
|
||||||
|
"por_status": por_status,
|
||||||
|
"validadas": validadas,
|
||||||
|
"total_em_aberto": str(total_aberto),
|
||||||
|
"total_vencido": str(total_vencido),
|
||||||
|
"total_excedente": str(total_excedente),
|
||||||
|
"total_nao_vinculado": str(total_nao_vinculado),
|
||||||
|
"contas_vencidas": contas_por_alerta["credito_vencido"],
|
||||||
|
"contas_com_diferenca": contas_por_alerta["diferenca_vinculo"],
|
||||||
|
"contas_pagamento_sem_titulo": contas_por_alerta["pagamento_sem_titulo"],
|
||||||
|
"contas_transitorias": contas_por_alerta["conta_transitoria"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_valida_conciliadas(conciliacao: ConciliacaoFornecedor) -> int:
|
||||||
|
"""Aplica a validação automática em todas as contas (criação da
|
||||||
|
conciliação). Devolve quantas mudaram."""
|
||||||
|
alteradas = [
|
||||||
|
conta
|
||||||
|
for conta, _, _, analise in _conciliacao_carrega(conciliacao)
|
||||||
|
if _conciliacao_sincroniza_validacao_automatica(conta, analise)
|
||||||
|
]
|
||||||
|
ConciliacaoFornecedorConta.objects.bulk_update(alteradas, ["validada", "validada_por", "validada_em"])
|
||||||
|
return len(alteradas)
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_atualiza_resumo(conciliacao: ConciliacaoFornecedor) -> dict[str, Any]:
|
||||||
|
resumo = _conciliacao_calcula_resumo(_conciliacao_carrega(conciliacao))
|
||||||
|
conciliacao.resumo = resumo
|
||||||
|
conciliacao.save(update_fields=["resumo"])
|
||||||
|
return resumo
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_resposta_conta(conta: ConciliacaoFornecedorConta, sincroniza_validacao: bool = True) -> Response:
|
||||||
|
"""Depois de um ajuste manual: devolve a conta reanalisada e o resumo
|
||||||
|
atualizado da conciliação (os cards do topo dependem dele). Depois de
|
||||||
|
vincular/desfazer, reaplica a validação automática; depois de o
|
||||||
|
contador marcar/desmarcar à mão (`sincroniza_validacao=False`), não."""
|
||||||
|
conciliacao = conta.conciliacao
|
||||||
|
conta = ConciliacaoFornecedorConta.objects.select_related("validada_por").get(pk=conta.pk)
|
||||||
|
[carregada] = _conciliacao_carrega(conciliacao, [conta])
|
||||||
|
if sincroniza_validacao and _conciliacao_sincroniza_validacao_automatica(conta, carregada[3]):
|
||||||
|
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_grava_contas(
|
||||||
|
conciliacao: ConciliacaoFornecedor,
|
||||||
|
razao: conciliacao_parser.RazaoExtraido,
|
||||||
|
tolerancia: conciliacao_motor.Tolerancia,
|
||||||
|
) -> None:
|
||||||
|
"""Grava contas, vínculos sugeridos pelo motor e lançamentos. O saldo
|
||||||
|
anterior diferente de zero vira um lançamento `eh_saldo_anterior`,
|
||||||
|
primeiro da conta, para participar da conciliação."""
|
||||||
|
contas = ConciliacaoFornecedorConta.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ConciliacaoFornecedorConta(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
for extraida in razao.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],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sugeridos = conciliacao_motor.concilia(
|
||||||
|
[
|
||||||
|
conciliacao_motor.ItemConciliacao(indice=pos, data=linha.data, natureza=linha.natureza, valor=linha.valor)
|
||||||
|
for pos, linha in enumerate(linhas)
|
||||||
|
],
|
||||||
|
tolerancia,
|
||||||
|
)
|
||||||
|
vinculos = ConciliacaoFornecedorVinculo.objects.bulk_create(
|
||||||
|
[ConciliacaoFornecedorVinculo(conta=conta, tipo=s.tipo, diferenca=s.diferenca) for s in sugeridos]
|
||||||
|
)
|
||||||
|
for vinculo, sugerido in zip(vinculos, sugeridos):
|
||||||
|
for pos in sugerido.indices:
|
||||||
|
linhas[pos].vinculo = vinculo
|
||||||
|
lancamentos += linhas
|
||||||
|
|
||||||
|
ConciliacaoFornecedorLancamento.objects.bulk_create(lancamentos, batch_size=2000)
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_gera_xlsx(conciliacao: ConciliacaoFornecedor) -> bytes:
|
||||||
|
contas_xlsx: list[conciliacao_exportacao.ContaXlsx] = []
|
||||||
|
pendencias: list[conciliacao_exportacao.PendenciaXlsx] = []
|
||||||
|
alertas_xlsx: list[conciliacao_exportacao.AlertaXlsx] = []
|
||||||
|
for conta, lancs, vincs, analise in _conciliacao_carrega(conciliacao):
|
||||||
|
contas_xlsx.append(
|
||||||
|
conciliacao_exportacao.ContaXlsx(
|
||||||
|
conta_numero=conta.conta_numero,
|
||||||
|
classificacao=conta.classificacao,
|
||||||
|
fornecedor=conta.fornecedor,
|
||||||
|
saldo_inicial=conta.saldo_inicial,
|
||||||
|
saldo_final=conta.saldo_final,
|
||||||
|
total_em_aberto=analise.total_em_aberto,
|
||||||
|
total_vencido=analise.total_vencido,
|
||||||
|
total_excedente=analise.total_excedente,
|
||||||
|
situacao=_CONCILIACAO_ROTULO_STATUS[analise.status],
|
||||||
|
validada=conta.validada,
|
||||||
|
observacao=conta.observacao,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for vinc in vincs:
|
||||||
|
saldo = analise.situacoes.get(-vinc.id)
|
||||||
|
if vinc.tipo != conciliacao_motor.TIPO_PARCIAL or saldo is None or not saldo.valor_em_aberto:
|
||||||
|
continue
|
||||||
|
titulo = min((l for l in lancs if l.vinculo_id == vinc.id and l.natureza == "C"), key=lambda l: l.data)
|
||||||
|
pendencias.append(
|
||||||
|
conciliacao_exportacao.PendenciaXlsx(
|
||||||
|
conta_numero=conta.conta_numero,
|
||||||
|
fornecedor=conta.fornecedor,
|
||||||
|
data=titulo.data,
|
||||||
|
sequencia=titulo.sequencia,
|
||||||
|
historico=f"{titulo.historico} (pagamento parcial vinculado)",
|
||||||
|
natureza="Crédito",
|
||||||
|
valor=titulo.valor,
|
||||||
|
valor_em_aberto=saldo.valor_em_aberto,
|
||||||
|
situacao=saldo.descricao,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for lanc in lancs:
|
||||||
|
situacao = analise.situacoes.get(lanc.id)
|
||||||
|
if situacao is None:
|
||||||
|
continue
|
||||||
|
pendencias.append(
|
||||||
|
conciliacao_exportacao.PendenciaXlsx(
|
||||||
|
conta_numero=conta.conta_numero,
|
||||||
|
fornecedor=conta.fornecedor,
|
||||||
|
data=lanc.data,
|
||||||
|
sequencia=lanc.sequencia,
|
||||||
|
historico=lanc.historico,
|
||||||
|
natureza="Crédito" if lanc.natureza == "C" else "Débito",
|
||||||
|
valor=lanc.valor,
|
||||||
|
valor_em_aberto=situacao.valor_em_aberto,
|
||||||
|
situacao=f"{situacao.descricao} Sugestão: {situacao.sugestao}" if situacao.sugestao else situacao.descricao,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for alerta in analise.alertas:
|
||||||
|
alertas_xlsx.append(
|
||||||
|
conciliacao_exportacao.AlertaXlsx(
|
||||||
|
conta_numero=conta.conta_numero,
|
||||||
|
fornecedor=conta.fornecedor,
|
||||||
|
severidade=_CONCILIACAO_ROTULO_SEVERIDADE[alerta.severidade],
|
||||||
|
mensagem=alerta.mensagem,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
periodo = (
|
||||||
|
f"{conciliacao.periodo_inicio:%d/%m/%Y} a {conciliacao.periodo_fim:%d/%m/%Y}"
|
||||||
|
if conciliacao.periodo_inicio and conciliacao.periodo_fim
|
||||||
|
else "sem lançamentos"
|
||||||
|
)
|
||||||
|
return conciliacao_exportacao.gera_xlsx(
|
||||||
|
empresa=f"{conciliacao.codigo_empresa} - {conciliacao.nome_empresa or 'Empresa não encontrada no Questor'}",
|
||||||
|
data_base=conciliacao.data_base,
|
||||||
|
periodo=periodo,
|
||||||
|
contas=contas_xlsx,
|
||||||
|
pendencias=pendencias,
|
||||||
|
alertas=alertas_xlsx,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
são internas), detalhe completo e exclusão."""
|
||||||
|
|
||||||
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||||
|
serializer_class = ConciliacaoFornecedorListSerializer
|
||||||
|
|
||||||
|
def get_permissions(self) -> list[BasePermission]:
|
||||||
|
return [PermissaoApp(*_CONCILIACAO_PERMISSAO)]
|
||||||
|
|
||||||
|
def get_queryset(self) -> QuerySet[ConciliacaoFornecedor]:
|
||||||
|
queryset = ConciliacaoFornecedor.objects.select_related("criado_por")
|
||||||
|
codigo = normalizar_codigo_empresa(self.request.query_params.get("codigo_empresa") or "")
|
||||||
|
if codigo:
|
||||||
|
queryset = queryset.filter(codigo_empresa=codigo)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||||
|
conciliacao = self.get_object()
|
||||||
|
carregado = _conciliacao_carrega(conciliacao)
|
||||||
|
dados = dict(ConciliacaoFornecedorListSerializer(conciliacao).data)
|
||||||
|
dados["resumo"] = _conciliacao_calcula_resumo(carregado)
|
||||||
|
dados["contas"] = [_conciliacao_conta_payload(*item) for item in carregado]
|
||||||
|
return Response(dados)
|
||||||
|
|
||||||
|
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||||
|
entrada = ConciliacaoFornecedorCreateSerializer(data=request.data)
|
||||||
|
entrada.is_valid(raise_exception=True)
|
||||||
|
dados = entrada.validated_data
|
||||||
|
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)})
|
||||||
|
|
||||||
|
tolerancia = conciliacao_motor.Tolerancia(
|
||||||
|
conciliacao_motor.TOLERANCIA_VALOR_PADRAO, conciliacao_motor.TOLERANCIA_PERCENTUAL_PADRAO
|
||||||
|
)
|
||||||
|
# Nome resolvido antes da transação: é uma consulta a banco externo.
|
||||||
|
nome_empresa = resolve_nome_empresa(dados["codigo_empresa"]) or ""
|
||||||
|
conciliacao: ConciliacaoFornecedor | None = None
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
conciliacao = ConciliacaoFornecedor(
|
||||||
|
codigo_empresa=dados["codigo_empresa"],
|
||||||
|
nome_empresa=nome_empresa,
|
||||||
|
data_base=timezone.localdate(),
|
||||||
|
periodo_inicio=razao.periodo_inicio,
|
||||||
|
periodo_fim=razao.periodo_fim,
|
||||||
|
nome_arquivo=arquivo.name[:255],
|
||||||
|
tolerancia_valor=tolerancia.valor,
|
||||||
|
tolerancia_percentual=tolerancia.percentual,
|
||||||
|
criado_por=request.user,
|
||||||
|
)
|
||||||
|
conciliacao.arquivo.save(arquivo.name, ContentFile(conteudo), save=False)
|
||||||
|
conciliacao.save()
|
||||||
|
_conciliacao_grava_contas(conciliacao, razao, tolerancia)
|
||||||
|
_conciliacao_valida_conciliadas(conciliacao)
|
||||||
|
_conciliacao_atualiza_resumo(conciliacao)
|
||||||
|
except Exception:
|
||||||
|
# upload de FileField não é transacional
|
||||||
|
if conciliacao is not None and conciliacao.arquivo:
|
||||||
|
conciliacao.arquivo.delete(save=False)
|
||||||
|
raise
|
||||||
|
return Response(ConciliacaoFornecedorListSerializer(conciliacao).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance: ConciliacaoFornecedor) -> None:
|
||||||
|
arquivo = instance.arquivo
|
||||||
|
with transaction.atomic():
|
||||||
|
instance.delete()
|
||||||
|
if arquivo:
|
||||||
|
arquivo.delete(save=False)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], url_path="nome-empresa")
|
||||||
|
def nome_empresa(self, request: Request) -> Response:
|
||||||
|
"""Nome da empresa no Questor pelo código digitado (mesma consulta e
|
||||||
|
cache de "Cadastro de Regras" do Plano de Saúde). `nome_empresa: null`
|
||||||
|
tanto para código inexistente quanto para Questor inacessível."""
|
||||||
|
codigo = normalizar_codigo_empresa(request.query_params.get("codigo_empresa") or "")
|
||||||
|
if not codigo:
|
||||||
|
return Response({"detail": "Informe codigo_empresa."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return Response({"codigo_empresa": codigo, "nome_empresa": resolve_nome_empresa(codigo)})
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"], url_path="exportar-xlsx")
|
||||||
|
def exportar_xlsx(self, request: Request, pk: str | None = None) -> HttpResponse:
|
||||||
|
conciliacao = self.get_object()
|
||||||
|
nome = f"conciliacao_fornecedores_{conciliacao.codigo_empresa}_{conciliacao.data_base:%Y%m%d}.xlsx"
|
||||||
|
resposta = HttpResponse(
|
||||||
|
_conciliacao_gera_xlsx(conciliacao),
|
||||||
|
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
)
|
||||||
|
resposta["Content-Disposition"] = f'attachment; filename="{nome}"'
|
||||||
|
return resposta
|
||||||
|
|
||||||
|
|
||||||
|
def _conciliacao_tipo_vinculo_manual(
|
||||||
|
conciliacao: ConciliacaoFornecedor, lancs: list[ConciliacaoFornecedorLancamento]
|
||||||
|
) -> tuple[str, Decimal]:
|
||||||
|
"""Tipo e diferença de um vínculo manual. Créditos maiores que os débitos
|
||||||
|
além da tolerância: pagamento parcial, o saldo continua em aberto. Dentro
|
||||||
|
da tolerância (ou pago a maior), fecha como vínculo com diferença
|
||||||
|
(juros/desconto). Usado ao criar e ao acrescentar lançamentos."""
|
||||||
|
diferenca = sum((lanc.valor if lanc.natureza == "C" else -lanc.valor for lanc in lancs), Decimal("0"))
|
||||||
|
tolerancia = conciliacao_motor.Tolerancia(conciliacao.tolerancia_valor, conciliacao.tolerancia_percentual)
|
||||||
|
total_creditos = sum((lanc.valor for lanc in lancs if lanc.natureza == "C"), Decimal("0"))
|
||||||
|
limite = Decimal(tolerancia.limite_centavos(int(total_creditos * 100))) / 100
|
||||||
|
tipo = conciliacao_motor.TIPO_PARCIAL if diferenca > limite else conciliacao_motor.TIPO_MANUAL
|
||||||
|
return tipo, diferenca
|
||||||
|
|
||||||
|
|
||||||
|
class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet):
|
||||||
|
"""Ajustes manuais numa conta: vincular lançamentos pendentes e marcar
|
||||||
|
a conta como validada (com observação)."""
|
||||||
|
|
||||||
|
queryset = ConciliacaoFornecedorConta.objects.select_related("conciliacao", "validada_por")
|
||||||
|
|
||||||
|
def get_permissions(self) -> list[BasePermission]:
|
||||||
|
return [PermissaoApp(*_CONCILIACAO_PERMISSAO)]
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def vincular(self, request: Request, pk: str | None = None) -> Response:
|
||||||
|
conta = self.get_object()
|
||||||
|
entrada = ConciliacaoFornecedorVincularSerializer(data=request.data)
|
||||||
|
entrada.is_valid(raise_exception=True)
|
||||||
|
ids = set(entrada.validated_data["lancamentos"])
|
||||||
|
with transaction.atomic():
|
||||||
|
lancs = list(ConciliacaoFornecedorLancamento.objects.select_for_update().filter(conta=conta, id__in=ids))
|
||||||
|
if len(lancs) != len(ids):
|
||||||
|
raise ValidationError({"lancamentos": "Há lançamentos que não pertencem a esta conta."})
|
||||||
|
if any(lanc.vinculo_id is not None for lanc in lancs):
|
||||||
|
raise ValidationError({"lancamentos": "Há lançamentos que já estão vinculados. Desfaça o vínculo antes."})
|
||||||
|
if len(lancs) < 2 or {lanc.natureza for lanc in lancs} != {"C", "D"}:
|
||||||
|
raise ValidationError({"lancamentos": "Selecione pelo menos um débito e um crédito."})
|
||||||
|
tipo, diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, lancs)
|
||||||
|
vinculo = ConciliacaoFornecedorVinculo.objects.create(
|
||||||
|
conta=conta, tipo=tipo, diferenca=diferenca, criado_por=request.user
|
||||||
|
)
|
||||||
|
ConciliacaoFornecedorLancamento.objects.filter(id__in=ids).update(vinculo=vinculo)
|
||||||
|
return _conciliacao_resposta_conta(conta)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def validar(self, request: Request, pk: str | None = None) -> Response:
|
||||||
|
conta = self.get_object()
|
||||||
|
entrada = ConciliacaoFornecedorValidarSerializer(data=request.data)
|
||||||
|
entrada.is_valid(raise_exception=True)
|
||||||
|
dados = entrada.validated_data
|
||||||
|
conta.validada = dados["validada"]
|
||||||
|
conta.validada_por = request.user if conta.validada else None
|
||||||
|
conta.validada_em = timezone.now() if conta.validada else None
|
||||||
|
if "observacao" in dados:
|
||||||
|
conta.observacao = dados["observacao"].strip()
|
||||||
|
conta.save(update_fields=["validada", "validada_por", "validada_em", "observacao"])
|
||||||
|
return _conciliacao_resposta_conta(conta, sincroniza_validacao=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet):
|
||||||
|
"""`DELETE` desfaz o vínculo: os lançamentos voltam a pendente.
|
||||||
|
`adicionar/` acrescenta lançamentos pendentes a um vínculo manual."""
|
||||||
|
|
||||||
|
queryset = ConciliacaoFornecedorVinculo.objects.select_related("conta__conciliacao")
|
||||||
|
|
||||||
|
def get_permissions(self) -> list[BasePermission]:
|
||||||
|
return [PermissaoApp(*_CONCILIACAO_PERMISSAO)]
|
||||||
|
|
||||||
|
def destroy(self, request: Request, pk: str | None = None) -> Response:
|
||||||
|
vinculo = self.get_object()
|
||||||
|
conta = vinculo.conta
|
||||||
|
with transaction.atomic():
|
||||||
|
vinculo.delete()
|
||||||
|
return _conciliacao_resposta_conta(conta)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def adicionar(self, request: Request, pk: str | None = None) -> Response:
|
||||||
|
"""Acrescenta lançamentos pendentes da mesma conta a um vínculo manual
|
||||||
|
(ex.: um segundo pagamento de um título já vinculado como pagamento
|
||||||
|
parcial), sem desfazer o vínculo. Tipo e diferença são recalculados
|
||||||
|
com a mesma regra da criação; o vínculo passa a ser do usuário atual."""
|
||||||
|
vinculo = self.get_object()
|
||||||
|
if vinculo.tipo not in (conciliacao_motor.TIPO_MANUAL, conciliacao_motor.TIPO_PARCIAL):
|
||||||
|
raise ValidationError({"detail": "Só é possível acrescentar lançamentos a um vínculo manual."})
|
||||||
|
entrada = ConciliacaoFornecedorVincularSerializer(data=request.data)
|
||||||
|
entrada.is_valid(raise_exception=True)
|
||||||
|
ids = set(entrada.validated_data["lancamentos"])
|
||||||
|
conta = vinculo.conta
|
||||||
|
with transaction.atomic():
|
||||||
|
novos = list(ConciliacaoFornecedorLancamento.objects.select_for_update().filter(conta=conta, id__in=ids))
|
||||||
|
if len(novos) != len(ids):
|
||||||
|
raise ValidationError({"lancamentos": "Há lançamentos que não pertencem a esta conta."})
|
||||||
|
if any(lanc.vinculo_id is not None for lanc in novos):
|
||||||
|
raise ValidationError({"lancamentos": "Há lançamentos que já estão vinculados. Desfaça o vínculo antes."})
|
||||||
|
ConciliacaoFornecedorLancamento.objects.filter(id__in=ids).update(vinculo=vinculo)
|
||||||
|
todos = list(ConciliacaoFornecedorLancamento.objects.filter(vinculo=vinculo))
|
||||||
|
vinculo.tipo, vinculo.diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, todos)
|
||||||
|
vinculo.criado_por = request.user
|
||||||
|
vinculo.save(update_fields=["tipo", "diferenca", "criado_por"])
|
||||||
|
return _conciliacao_resposta_conta(conta)
|
||||||
|
|||||||
1054
static/css/conciliacao-fornecedores.css
Normal file
1054
static/css/conciliacao-fornecedores.css
Normal file
File diff suppressed because it is too large
Load Diff
1038
static/js/conciliacao-fornecedores.js
Normal file
1038
static/js/conciliacao-fornecedores.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -165,6 +165,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -166,6 +166,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
595
templates/conciliacao-fornecedores.html
Normal file
595
templates/conciliacao-fornecedores.html
Normal file
@ -0,0 +1,595 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>P.I.D. — Conciliação de Fornecedores</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{% static 'img/pid-icone-escuro.svg' %}" />
|
||||||
|
<link rel="stylesheet" href="{% static 'css/tokens.css' %}" />
|
||||||
|
<link rel="stylesheet" href="{% static 'css/base.css' %}" />
|
||||||
|
<link rel="stylesheet" href="{% static 'css/layout.css' %}" />
|
||||||
|
<link rel="stylesheet" href="{% static 'css/components.css' %}" />
|
||||||
|
<link rel="stylesheet" href="{% static 'css/perfis-acesso.css' %}" />
|
||||||
|
<link rel="stylesheet" href="{% static 'css/conciliacao-fornecedores.css' %}" />
|
||||||
|
<script src="{% static 'js/theme.js' %}" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell" id="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<a class="sidebar__brand" href="portal.html" id="sidebar-brand-link" aria-label="Ir para a tela Principal">
|
||||||
|
<svg class="sidebar__logo sidebar__logo--full" viewBox="0 0 300 80" aria-hidden="true" focusable="false">
|
||||||
|
<g transform="translate(8,8) scale(1)">
|
||||||
|
<path d="M10 4 h34 l14 14 v40 a2 2 0 0 1 -2 2 H8 a2 2 0 0 1 -2 -2 V6 a2 2 0 0 1 2 -2 z" fill="#7B5BA8"></path>
|
||||||
|
<rect x="22" y="4" width="20" height="16" fill="#C6A24A"></rect>
|
||||||
|
<rect x="34" y="7" width="5" height="10" fill="#7B5BA8"></rect>
|
||||||
|
<rect x="12" y="26" width="40" height="30" rx="2" fill="#241C33"></rect>
|
||||||
|
<g class="pid-icon-eye">
|
||||||
|
<circle cx="24" cy="38" r="5" fill="#F2EDE3"></circle>
|
||||||
|
<circle cx="26" cy="36.2" r="1.6" fill="#241C33"></circle>
|
||||||
|
</g>
|
||||||
|
<g class="pid-icon-eye">
|
||||||
|
<circle cx="40" cy="38" r="5" fill="#F2EDE3"></circle>
|
||||||
|
<circle cx="42" cy="36.2" r="1.6" fill="#241C33"></circle>
|
||||||
|
</g>
|
||||||
|
<path d="M25 47 q7 6 14 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
|
||||||
|
</g>
|
||||||
|
<text x="86" y="42" font-family="'Space Grotesk',Helvetica,Arial,sans-serif" font-size="34" font-weight="700" letter-spacing="1.4" fill="#F2EDE3">P.I.D.</text>
|
||||||
|
<text x="88" y="61" font-family="'DM Mono',ui-monospace,monospace" font-size="9.5" letter-spacing="2.6" fill="#C6A24A">PORTAL INTERNO DA DE PAULA</text>
|
||||||
|
</svg>
|
||||||
|
<svg class="sidebar__logo sidebar__logo--icon" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M10 4 h34 l14 14 v40 a2 2 0 0 1 -2 2 H8 a2 2 0 0 1 -2 -2 V6 a2 2 0 0 1 2 -2 z" fill="#7B5BA8"></path>
|
||||||
|
<rect x="22" y="4" width="20" height="16" fill="#C6A24A"></rect>
|
||||||
|
<rect x="34" y="7" width="5" height="10" fill="#7B5BA8"></rect>
|
||||||
|
<rect x="12" y="26" width="40" height="30" rx="2" fill="#241C33"></rect>
|
||||||
|
<g class="pid-icon-eye">
|
||||||
|
<circle cx="24" cy="38" r="5" fill="#F2EDE3"></circle>
|
||||||
|
<circle cx="26" cy="36.2" r="1.6" fill="#241C33"></circle>
|
||||||
|
</g>
|
||||||
|
<g class="pid-icon-eye">
|
||||||
|
<circle cx="40" cy="38" r="5" fill="#F2EDE3"></circle>
|
||||||
|
<circle cx="42" cy="36.2" r="1.6" fill="#241C33"></circle>
|
||||||
|
</g>
|
||||||
|
<path d="M25 47 q7 6 14 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="sidebar__nav">
|
||||||
|
<ul class="nav-list">
|
||||||
|
<li data-section="principal">
|
||||||
|
<a href="portal.html" class="nav-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" stroke-linejoin="round"/></svg>
|
||||||
|
<span class="nav-item__label">Principal</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li data-section="ramais">
|
||||||
|
<a href="ramais.html" class="nav-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4c0 9 7 16 16 16l3-4-6-3-2 2c-3-1.5-5-3.5-6-6l2-2-3-6z" stroke-linejoin="round"/></svg>
|
||||||
|
<span class="nav-item__label">Ramais</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li data-section="calendario">
|
||||||
|
<a href="#" class="nav-item" id="calendario-depaula-btn">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 9h18M8 3v4M16 3v4" stroke-linecap="round"/></svg>
|
||||||
|
<span class="nav-item__label">Calendário De Paula</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li data-section="calendario-individual">
|
||||||
|
<a href="calendario-individual.html" class="nav-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 9h18M8 3v4M16 3v4" stroke-linecap="round"/><path d="M9 15l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span class="nav-item__label">Calendário Individual</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="portais" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||||
|
<span class="nav-item__label">Portais</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li data-app="portal-do-cliente"><a href="#" class="nav-subitem">Portal do Cliente</a></li>
|
||||||
|
<li data-app="portal-fiscal"><a href="#" class="nav-subitem">Portal Fiscal</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="geradoc" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v4h4" stroke-linejoin="round"/></svg>
|
||||||
|
<span class="nav-item__label">Geradoc</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li data-app="simulacao-custo-contratacao"><a href="custo-contratacao.html" class="nav-subitem">Simulação de Custo de Contratação</a></li>
|
||||||
|
<li data-app="indicador-desempenho"><a href="indicador-desempenho.html" class="nav-subitem">Indicador de Desempenho</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="relatorios" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M12 20V4M20 20v-7" stroke-linecap="round"/></svg>
|
||||||
|
<span class="nav-item__label">Relatórios</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
|
||||||
|
<li class="nav-subgroup" data-subgroup="qualidade">
|
||||||
|
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||||
|
<span>Qualidade</span>
|
||||||
|
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-subgroup__list">
|
||||||
|
<li data-app="nao-conformidades">
|
||||||
|
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||||
|
Não Conformidades
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||||
|
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||||
|
<span>Contabilidade</span>
|
||||||
|
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-subgroup__list">
|
||||||
|
<li data-app="dashboard-contabil">
|
||||||
|
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||||
|
Relatório Contábil
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="relatorios-gerenciais" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18M7 15l4-5 3 3 5-7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span class="nav-item__label">Relatórios Gerenciais</span>
|
||||||
|
<span class="badge-restricted" data-restricted-badge hidden>Restrito</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li data-app="visao-diretoria"><a href="#" class="nav-subitem">Visão Diretoria</a></li>
|
||||||
|
<li data-app="visao-gerencial"><a href="#" class="nav-subitem">Visão Gerencial</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="utilitarios" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 7l3 3-8 8-4 1 1-4z" stroke-linejoin="round"/><path d="M12 9l3 3" stroke-linecap="round"/></svg>
|
||||||
|
<span class="nav-item__label">Utilitários</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem is-active">Conciliação de Fornecedores</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="integracoes" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="12" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M8.6 10.6L15.4 7.4M8.6 13.4l6.8 3.2" stroke-linecap="round"/></svg>
|
||||||
|
<span class="nav-item__label">Integrações</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li data-app="questor"><a href="#" class="nav-subitem">Questor</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="auditorias" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l7 4v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V7z" stroke-linejoin="round"/><path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span class="nav-item__label">Auditorias</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li class="nav-subgroup" data-subgroup="consultoria-tributaria">
|
||||||
|
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||||
|
<span>Consultoria Tributária</span>
|
||||||
|
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-subgroup__list">
|
||||||
|
<li data-app="controle-simples-nacional">
|
||||||
|
<a href="#" class="nav-subitem nav-subitem--tool">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||||
|
Controle Simples Nacional
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-subgroup" data-subgroup="fisco-contabil">
|
||||||
|
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||||
|
<span>Fisco/Contábil</span>
|
||||||
|
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-subgroup__list">
|
||||||
|
<li data-app="auditoria-de-balancetes">
|
||||||
|
<a href="#" class="nav-subitem nav-subitem--tool">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||||
|
Auditoria de Balancetes
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="links-ferramentas" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>
|
||||||
|
<span class="nav-item__label">Links & Ferramentas</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li data-app="links-ferramentas-visualizar"><a href="links-ferramentas.html" class="nav-subitem">Links & Ferramentas</a></li>
|
||||||
|
<li data-app="acessos-gerais-visualizar"><a href="acessos-gerais.html" class="nav-subitem">Acessos Gerais</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="solicitacoes" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 10h.01M12 10h.01M16 10h.01M21 12a9 9 0 1 1-4.4-7.7L21 3l-1.2 4.5A8.96 8.96 0 0 1 21 12z"/></svg>
|
||||||
|
<span class="nav-item__label">Solicitações</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li data-app="controle-notificacoes"><a href="https://form.asana.com/?k=PcELrDLzN969CwpiRBLtFA&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Controle de Notificações</a></li>
|
||||||
|
<li data-app="ordem-servico"><a href="#" class="nav-subitem">Ordem de Serviço</a></li>
|
||||||
|
<li data-app="pimi"><a href="https://form.asana.com/?k=XfBww25lGUyPVrInNcLyuQ&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">P.I.M.I.</a></li>
|
||||||
|
<li data-app="programa-indicacoes"><a href="https://form.asana.com/?k=y0mTeaRTOAoC0r0XP8ee0Q&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Programa de Indicações</a></li>
|
||||||
|
<li data-app="pos-vendas"><a href="https://form.asana.com/?k=jRAztLip-_BknWPsGmEcZA&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Pós-Vendas</a></li>
|
||||||
|
<li data-app="sugestao-inovacao"><a href="https://form.asana.com/?k=Zn8cLeT97R7FTUrsNB8Yaw&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Sugestão de Inovação</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-section="administracao" class="nav-group">
|
||||||
|
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" stroke-linejoin="round"/></svg>
|
||||||
|
<span class="nav-item__label">Administração</span>
|
||||||
|
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-group__list">
|
||||||
|
<li data-section="perfis-acesso"><a href="perfis-acesso.html" class="nav-subitem">Perfis de Acesso</a></li>
|
||||||
|
<li data-section="usuarios"><a href="usuarios.html" class="nav-subitem">Usuários</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<header class="topbar">
|
||||||
|
<button type="button" class="icon-btn" id="sidebar-toggle" aria-label="Alternar menu">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<h1 class="topbar__title">Conciliação de Fornecedores</h1>
|
||||||
|
|
||||||
|
<div class="topbar__actions">
|
||||||
|
<button type="button" class="icon-btn" id="theme-toggle" aria-label="Ativar modo claro">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="notifications">
|
||||||
|
<button type="button" class="icon-btn" id="notif-bell" aria-label="Notificações">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" stroke-linejoin="round"/><path d="M9.5 20a2.5 2.5 0 0 0 5 0"/></svg>
|
||||||
|
<span class="notif-badge" id="notif-badge" hidden>0</span>
|
||||||
|
</button>
|
||||||
|
<div class="notif-dropdown" id="notif-dropdown" hidden>
|
||||||
|
<div class="notif-dropdown__header">
|
||||||
|
<span>Notificações</span>
|
||||||
|
<div class="notif-dropdown__header-actions">
|
||||||
|
<button type="button" id="notif-history-btn">Histórico</button>
|
||||||
|
<button type="button" id="notif-clear-all">Limpar tudo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="notif-list" id="notif-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="account-menu">
|
||||||
|
<button type="button" class="persona-chip" id="persona-chip">
|
||||||
|
<span class="persona-chip__icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
</span>
|
||||||
|
<span id="persona-chip-label">Usuário</span>
|
||||||
|
</button>
|
||||||
|
<div class="account-dropdown" id="account-dropdown" hidden>
|
||||||
|
<div class="account-dropdown__header">
|
||||||
|
<p class="account-dropdown__name" id="account-name"></p>
|
||||||
|
<p class="account-dropdown__profile" id="account-profile"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="account-dropdown__item" id="account-manage-account-btn">Gerenciar Usuário</button>
|
||||||
|
<a href="perfis-acesso.html" class="account-dropdown__item" id="account-manage-link" hidden>Gerenciar Perfis de Acesso</a>
|
||||||
|
<div class="account-dropdown__section">
|
||||||
|
<p class="account-dropdown__section-title">Tema de cores</p>
|
||||||
|
<div class="theme-swatches" id="theme-swatches"></div>
|
||||||
|
</div>
|
||||||
|
<div class="account-dropdown__section">
|
||||||
|
<p class="account-dropdown__section-title">Temas sazonais</p>
|
||||||
|
<button type="button" class="seasonal-theme-toggle" id="seasonal-theme-toggle" aria-pressed="true">
|
||||||
|
<span class="seasonal-theme-toggle__dot"></span>
|
||||||
|
<span id="seasonal-theme-toggle-label">Ativo</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="account-dropdown__item account-dropdown__logout" data-logout>Sair</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="page-content page-content--wide">
|
||||||
|
<div id="conc-no-access" class="no-access" hidden>
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<h2>Sem acesso a Conciliação de Fornecedores</h2>
|
||||||
|
<p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Inovação a liberação em Perfis de Acesso.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="conc-content" hidden>
|
||||||
|
<!-- Histórico + nova conciliação -->
|
||||||
|
<section id="conc-list-view">
|
||||||
|
<div class="conc-header">
|
||||||
|
<div>
|
||||||
|
<h2>Conciliação de Fornecedores</h2>
|
||||||
|
<p class="conc-subtitle">Anexe a exportação do razão contábil de fornecedores do Questor: a ferramenta vincula débitos e créditos de cada fornecedor por valor e data e aponta o que ficou pendente.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-solid" id="conc-nova-btn">Nova Conciliação</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conc-form" id="conc-form" hidden>
|
||||||
|
<div class="conc-form__grid">
|
||||||
|
<div class="modal-field">
|
||||||
|
<label for="conc-form-codigo">Código da empresa</label>
|
||||||
|
<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>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Razão de fornecedores (.xlsx ou .csv)</label>
|
||||||
|
<div class="conc-file-field">
|
||||||
|
<label class="btn-outline conc-file-field__btn" for="conc-form-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-form-arquivo" accept=".xlsx,.csv" class="conc-file-field__input" />
|
||||||
|
<span class="conc-file-field__name" id="conc-form-arquivo-nome">Nenhum arquivo selecionado</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="modal-error" id="conc-form-erro"></p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conc-toolbar">
|
||||||
|
<div class="pa-search">
|
||||||
|
<input type="search" id="conc-lista-busca" placeholder="Buscar por código ou empresa..." autocomplete="off" />
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pa-table-wrap">
|
||||||
|
<table class="pa-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Empresa</th>
|
||||||
|
<th>Data-base</th>
|
||||||
|
<th>Período</th>
|
||||||
|
<th class="conc-num">Fornecedores</th>
|
||||||
|
<th class="conc-num">Conciliados</th>
|
||||||
|
<th class="conc-num">Divergentes</th>
|
||||||
|
<th class="conc-num">Em aberto</th>
|
||||||
|
<th>Criado por</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="conc-lista-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="conc-empty" id="conc-lista-vazia" hidden>Nenhuma conciliação realizada ainda.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Detalhe de uma conciliação -->
|
||||||
|
<section id="conc-detail-view" hidden>
|
||||||
|
<button type="button" class="btn-ghost conc-voltar-btn" id="conc-voltar-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 6l-6 6 6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Voltar ao histórico
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="conc-empresa">
|
||||||
|
<div class="conc-empresa__principal">
|
||||||
|
<span class="conc-empresa__codigo" id="conc-det-codigo"></span>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="conc-cards" id="conc-cards"></div>
|
||||||
|
<div class="conc-alertas-gerais" id="conc-alertas-gerais"></div>
|
||||||
|
|
||||||
|
<div class="conc-toolbar conc-toolbar--fixa">
|
||||||
|
<div class="conc-chip-row" id="conc-filtro-status">
|
||||||
|
<button type="button" class="conc-chip is-active" data-conc-status="">Todos</button>
|
||||||
|
<button type="button" class="conc-chip" data-conc-status="divergente">Divergentes</button>
|
||||||
|
<button type="button" class="conc-chip" data-conc-status="atencao">Atenção</button>
|
||||||
|
<button type="button" class="conc-chip" data-conc-status="pendente">Pendentes de pagamento</button>
|
||||||
|
<button type="button" class="conc-chip" data-conc-status="conciliada">Conciliados</button>
|
||||||
|
<button type="button" class="conc-chip" data-conc-status="validada">Validados</button>
|
||||||
|
</div>
|
||||||
|
<label class="conc-toggle"><input type="checkbox" id="conc-mostrar-validadas" /> Mostrar validadas <span class="conc-chip__count" id="conc-mostrar-validadas-qtd">0</span></label>
|
||||||
|
<div class="pa-search">
|
||||||
|
<input type="search" id="conc-contas-busca" placeholder="Buscar fornecedor ou conta..." autocomplete="off" />
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="conc-filtro-alerta" id="conc-filtro-alerta" hidden></p>
|
||||||
|
|
||||||
|
<div class="pa-table-wrap conc-table-wrap">
|
||||||
|
<table class="pa-table conc-table" id="conc-contas-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="conc-expand-col"></th>
|
||||||
|
<th data-sort="conta_numero">Conta <span class="conc-sort-icon">↕</span></th>
|
||||||
|
<th data-sort="fornecedor">Fornecedor <span class="conc-sort-icon">↕</span></th>
|
||||||
|
<th class="conc-num" data-sort="saldo_inicial">Saldo inicial <span class="conc-sort-icon">↕</span></th>
|
||||||
|
<th class="conc-num" data-sort="saldo_final">Saldo residual <span class="conc-sort-icon">↕</span></th>
|
||||||
|
<th class="conc-num" data-sort="total_vencido">Em aberto > 3 meses <span class="conc-sort-icon">↕</span></th>
|
||||||
|
<th data-sort="status">Situação <span class="conc-sort-icon">↕</span></th>
|
||||||
|
<th class="conc-validado-col" data-sort="validada">Validado <span class="conc-sort-icon">↕</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="conc-contas-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="conc-empty" id="conc-contas-vazia" hidden>Nenhum fornecedor para este filtro.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="manage-account-modal" hidden>
|
||||||
|
<div class="modal-card" id="manage-account-modal-card">
|
||||||
|
<h2 class="modal-card__title">Gerenciar Usuário</h2>
|
||||||
|
<div class="modal-field">
|
||||||
|
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field" id="manage-account-liderados-field" hidden>
|
||||||
|
<label>Usuários sob sua liderança</label>
|
||||||
|
<div class="dual-select">
|
||||||
|
<div class="dual-select__panel">
|
||||||
|
<div class="dual-select__panel-header">
|
||||||
|
<button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
|
||||||
|
</div>
|
||||||
|
<div class="dual-select__table-wrap">
|
||||||
|
<table class="dual-select__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
|
||||||
|
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
|
||||||
|
<th>Departamento</th>
|
||||||
|
</tr>
|
||||||
|
<tr class="dual-select__filters">
|
||||||
|
<th></th>
|
||||||
|
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
|
||||||
|
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="manage-account-liderados-available-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dual-select__panel">
|
||||||
|
<div class="dual-select__panel-header">
|
||||||
|
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
|
||||||
|
</div>
|
||||||
|
<div class="dual-select__table-wrap">
|
||||||
|
<table class="dual-select__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
|
||||||
|
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
|
||||||
|
<th>Departamento</th>
|
||||||
|
</tr>
|
||||||
|
<tr class="dual-select__filters">
|
||||||
|
<th></th>
|
||||||
|
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
|
||||||
|
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="manage-account-liderados-linked-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="modal-error" id="manage-account-liderados-error"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-outline" id="manage-account-close-btn">Fechar</button>
|
||||||
|
<button type="button" class="btn-solid" id="manage-account-save-btn" hidden>Salvar liderados</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="password-modal" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<h2 class="modal-card__title">Alterar senha</h2>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label for="pwd-current">Senha atual</label>
|
||||||
|
<div class="password-field">
|
||||||
|
<input type="password" id="pwd-current" autocomplete="current-password" />
|
||||||
|
<button type="button" class="password-field__toggle" id="pwd-current-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label for="pwd-new">Nova senha</label>
|
||||||
|
<div class="password-field">
|
||||||
|
<input type="password" id="pwd-new" autocomplete="new-password" />
|
||||||
|
<button type="button" class="password-field__toggle" id="pwd-new-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label for="pwd-confirm">Confirmar nova senha</label>
|
||||||
|
<div class="password-field">
|
||||||
|
<input type="password" id="pwd-confirm" autocomplete="new-password" />
|
||||||
|
<button type="button" class="password-field__toggle" id="pwd-confirm-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="modal-error" id="pwd-error"></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-outline" id="pwd-cancel-btn">Cancelar</button>
|
||||||
|
<button type="button" class="btn-solid" id="pwd-save-btn">Salvar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="notif-history-modal" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<h2 class="modal-card__title">Histórico de Notificações</h2>
|
||||||
|
<ul class="notif-list" id="notif-history-list"></ul>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-outline" id="notif-history-close-btn">Fechar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="calendario-depaula-modal" hidden>
|
||||||
|
<div class="modal-card iframe-modal-card">
|
||||||
|
<div class="iframe-modal-header">
|
||||||
|
<h2 class="modal-card__title">Calendário De Paula</h2>
|
||||||
|
<button type="button" class="icon-btn" id="calendario-depaula-close-btn" aria-label="Fechar">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<iframe id="calendario-depaula-iframe" class="iframe-modal-iframe" title="Calendário De Paula"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'js/api.js' %}"></script>
|
||||||
|
<script src="{% static 'js/confirm-modal.js' %}"></script>
|
||||||
|
<script src="{% static 'js/seasonal-theme.js' %}"></script>
|
||||||
|
<script src="{% static 'js/sidebar-brand.js' %}"></script>
|
||||||
|
<script src="{% static 'js/dual-select.js' %}"></script>
|
||||||
|
<script src="{% static 'js/profiles.js' %}"></script>
|
||||||
|
<script src="{% static 'js/auth.js' %}"></script>
|
||||||
|
<script src="{% static 'js/access.js' %}"></script>
|
||||||
|
<script src="{% static 'js/account.js' %}"></script>
|
||||||
|
<script src="{% static 'js/favorites.js' %}"></script>
|
||||||
|
<script src="{% static 'js/events.js' %}"></script>
|
||||||
|
<script src="{% static 'js/sidebar.js' %}"></script>
|
||||||
|
<script src="{% static 'js/notifications.js' %}"></script>
|
||||||
|
<script src="{% static 'js/conciliacao-fornecedores.js' %}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -165,6 +165,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -166,6 +166,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -166,6 +166,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem is-active">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem is-active">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -166,6 +166,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem is-active">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem is-active">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -166,6 +166,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -166,6 +166,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -166,6 +166,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -165,6 +165,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -169,6 +169,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -166,6 +166,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -165,6 +165,7 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||||
|
<li data-app="conciliacao-fornecedores"><a href="conciliacao-fornecedores.html" class="nav-subitem">Conciliação de Fornecedores</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user