Nova aplicação - Conciliação de Fornecedores

This commit is contained in:
Gabriel 2026-09-25 08:32:00 -03:00
parent df1f4e6a1f
commit 0287de814e
38 changed files with 4991 additions and 2 deletions

View File

@ -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",

View File

@ -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`).

View File

@ -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) |

View File

@ -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",

View File

@ -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",)

View File

@ -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"},

View 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.

View 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).

View 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`.

View 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,
)

View 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()

View 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

View 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,
)

View File

@ -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

View 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'],
},
),
]

View 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'),
),
]

View File

@ -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}"

View File

@ -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)

View File

@ -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"),

View File

@ -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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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>

View 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 &amp; 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 &amp; 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&amp;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&amp;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&amp;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&amp;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&amp;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 &gt; 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>