Reestruturação das regras de conciliação

This commit is contained in:
Gabriel 2026-10-02 17:55:50 -03:00
parent 543b1171c7
commit 201bc4eff4
12 changed files with 2563 additions and 933 deletions

View File

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

View File

@ -328,3 +328,241 @@ Pedido do usuário: o arquivo anexado ficava em `media/conciliacao_fornecedores/
- Antes, a pedido do usuário, as conciliações #22 (2017) e #23 (1751) foram excluídas para refazer os testes, e os arquivos de `media/conciliacao_fornecedores/` apagados; depois o usuário disse que não precisava excluir, e o único versionado no git foi restaurado (os demais têm cópia em `Projetos\Conciliação`). A conciliação #24 (2017), criada pelo usuário durante a mudança, ficou intacta (o arquivo dela também foi apagado, sem impacto). - Antes, a pedido do usuário, as conciliações #22 (2017) e #23 (1751) foram excluídas para refazer os testes, e os arquivos de `media/conciliacao_fornecedores/` apagados; depois o usuário disse que não precisava excluir, e o único versionado no git foi restaurado (os demais têm cópia em `Projetos\Conciliação`). A conciliação #24 (2017), criada pelo usuário durante a mudança, ficou intacta (o arquivo dela também foi apagado, sem impacto).
Mexe em models.py/views.py e migração: exige reiniciar o `runserver`. Mexe em models.py/views.py e migração: exige reiniciar o `runserver`.
### Rodada 60 — Colunas do histórico enxugadas (2026-10-02)
Pedido do usuário: na tabela do histórico de conciliações, deixar só a quantidade de fornecedores e a de contas com alerta alto, sem a data-base, e mostrar quem mexeu por último em vez de quem criou.
- Coluna "Conciliados" removida (`conciliacao-fornecedores.html` e `renderHistorico()` em `conciliacao-fornecedores.js`).
- Coluna "Divergentes" renomeada para "Críticos" (mesmo valor: `resumo.por_status.divergente`). Só o rótulo dessa coluna mudou; o chip "Divergentes" do detalhe, o status `divergente` e o relatório continuam com o nome antigo.
- Coluna "Data-base" removida (a data-base continua no detalhe e no relatório).
- "Criado por" virou "Última atualização": nome e data/hora de `ultima_atualizacao` (última atualização do razão, já no payload); sem atualização, a criação (`criado_por_nome`/`criado_em`). Só a atualização do razão conta; vincular, desfazer ou validar não muda essa coluna.
Só HTML/JS: não exige reiniciar o `runserver`.
### Rodada 61 — Cards do detalhe e situação "Divergente" vira "Crítico" (2026-10-02)
Pedido do usuário: cards que digam o que falta fazer, em vez de somas que não pedem ação.
- Card 1, "Títulos em aberto" (soma de `total_em_aberto`) → **"Pagamentos a vincular"**: soma de `total_nao_vinculado` e quantidade de fornecedores; clicável, filtra pelo alerta `pagamento_sem_titulo`. A etiqueta "com pagamento não vinculado" saiu (duplicava o card).
- Card 2, "Em aberto há mais de 3 meses": o valor passa a ser a **quantidade de fornecedores pendentes de validação** na aba própria (antes, a soma de `total_vencido`), com "X de Y validado(s)" abaixo.
- Card 3, "Pagamentos sem título" (soma de `total_excedente`) → **"Críticos pendentes"**: contas com status `divergente` e `validada` falso; clicável, aplica o filtro do chip "Críticos" (as validadas já ficam escondidas por padrão).
- Barra de validação: o trilho (parte que falta) ganhou fundo verde translúcido e contorno, e a barra passou de 8 para 10px.
- Rótulo da situação `divergente`: "Divergente" → **"Crítico"** no JS (`PID_CONC_STATUS`, etiqueta da linha e chip "Críticos"), no XLSX (`_CONCILIACAO_ROTULO_STATUS` em views.py) e no relatório (`ROTULO_STATUS` e o risco "Conta em situação Crítico validada sem observação" em `relatorio.py`; textos do template). Chave interna sem mudança.
Mexe em views.py/relatorio.py: exige reiniciar o `runserver`.
### Rodada 62 — Cabeçalho do detalhe (2026-10-02)
Pedido do usuário, no cabeçalho da empresa do detalhe (`renderCabecalho()`):
- "EMPRESA XXXX" (`.conc-empresa__codigo`) no mesmo tamanho do nome (1,05rem; era 0,75rem).
- "Data-base" removida do cabeçalho (continua no relatório e no XLSX).
- "Processado por" e "Razão atualizado por" viraram uma só "Última atualização": quem atualizou o razão por último e quando; sem atualização, quem criou (mesma regra da coluna da lista, rodada 60).
Só JS/CSS: não exige reiniciar o `runserver`.
### Rodada 63 — Detalhe da validação no balão da barra (2026-10-02)
Pedido do usuário: o texto "N ainda para revisar · X% concluído" saiu de baixo da barra de validação e passou a aparecer no balão do Portal ao passar o mouse na barra (`data-conc-tooltip` + `data-conc-tooltip-sempre` em `.conc-progresso__barra`, sem `title` nativo).
Só JS: não exige reiniciar o `runserver`.
### Rodada 64 — Um balão para cada parte da barra de validação (2026-10-02)
Pedido do usuário: a parte preenchida mostra o que foi concluído e a parte vazia, o que falta.
- `.conc-progresso__barra` virou `flex` com dois filhos: `.conc-progresso__feito` (largura = %, balão "N validado(s) · X% concluído") e `.conc-progresso__falta` (`flex: 1`, transparente sobre o trilho, balão "N ainda para revisar · Y% pendente", Y = 100 − X para os dois somarem 100). Cada parte só é desenhada se tiver quantidade.
- Destaque da parte sob o mouse (pedido do usuário na sequência): a preenchida clareia (`#3fcf8e`) e o trilho apaga; a que falta fica num verde mais forte com tracejado diagonal (`repeating-linear-gradient`; o verde liso ficava igual ao verde apagado da preenchida, e o dourado testado no meio foi descartado pelo usuário) e a preenchida fica com `opacity: 0.45`. Só CSS, via `:hover`/`:has()`.
- Balão da barra mais caprichado (pedido do usuário): variante `.conc-tooltip--rico`, ativada por `data-conc-tooltip-titulo` (+ `-valor` e `-cor`) em `pidConcMostraTooltip()`: marcador de cor (liso para "Concluído", tracejado para "Pendente"), título em caixa alta, percentual em destaque à direita, "N de M fornecedores validados / ainda para revisar" abaixo, centrado na parte da barra com uma seta (`--conc-tooltip-seta`, calculada no JS; vira para cima se o balão abrir abaixo). Os demais balões da tela não mudam.
- Card "Em aberto há mais de 3 meses" (pedido do usuário, entre as opções sugeridas): o valor passa a ser a **soma dos títulos antigos dos fornecedores ainda sem revisão** (`total_vencido` das contas com `vencidos_validados` falso), com "ainda sem revisão · N fornecedor(es) a revisar · X de Y validado(s)" abaixo. Antes (rodada 61) era só a contagem de fornecedores. Na sequência, o usuário pediu o **saldo total em aberto** (soma de `total_vencido` de todos, revisados ou não), e o detalhe ficou "N fornecedor(es) a revisar · X de Y validado(s)". Por fim, o usuário esclareceu que queria o **saldo em aberto sem o recorte de 3 meses**: o card virou "Títulos em aberto" (soma de `total_em_aberto` e quantos fornecedores; "disponíveis para vincular os pagamentos", par do primeiro card), sem clique (o atalho para a aba dos títulos antigos saiu; ela continua pela aba).
- Tracejado da parte que falta (na barra e no marcador do balão) em vermelho (`--danger-rgb`), pedido do usuário; na sequência, o fundo verde saiu (na barra, o trilho fica transparente enquanto o mouse está na parte que falta; o marcador ganhou contorno vermelho): só o tracejado.
Só JS/CSS: não exige reiniciar o `runserver`.
### Rodada 65 — Chips da lista por validação (2026-10-02)
Pedido do usuário: os chips por situação (Todos/Críticos/Atenção/Conciliados/Validados) confundiam, porque misturavam situação calculada com validação.
- Chips novos (`PID_CONC_GRUPOS`, `data-conc-grupo`, `?grupo=` na URL; o antigo `?status=` é ignorado): **Pendentes** (padrão, não validadas), **Parcial** (validadas com vínculo `parcial` com `diferenca` > 0, ou seja, saldo a pagar que segue para os próximos meses), **Saldo em aberto** (validadas, sem parcial em aberto, `saldo_final` ≠ 0), **Conciliados** (validadas, sem parcial em aberto, `saldo_final` = 0) e **Validados** (todas as validadas). Parcial/Saldo em aberto/Conciliados dividem as validadas sem sobra. Interpretação da implementação: os três exigem conta validada (o pedido só diz "validados" no Saldo em aberto) e uma validada com saldo devedor (negativo) cai em "Saldo em aberto"; confirmar se o usuário disser o contrário.
- Toggle "Mostrar validadas" removido (o chip Pendentes faz o papel).
- Card "Críticos pendentes" virou um filtro de card como os outros (`filtroAlerta = "criticos_pendentes"`, com a faixa "Filtrando por"); filtro de card/etiqueta passa a valer sobre todas as contas, validadas ou não, para a lista bater com o número do card. O rótulo do filtro `pagamento_sem_titulo` virou "Pagamentos a vincular" (nome do card).
- Coluna "Validado": data e hora da validação abaixo do checkbox (`.conc-validado-data`, coluna de 90 para 130px), e a ordenação dela passou a ser por `validada_em`.
- Ordem padrão por chip (`sortPadrao()`): Pendentes por situação (críticos primeiro); os chips de validadas da validação mais recente para a mais antiga. Trocar de chip volta à ordem padrão dele.
Só HTML/JS/CSS: não exige reiniciar o `runserver`.
### Rodada 66 — Sai a coluna "Em aberto > 3 meses" da tabela de fornecedores (2026-10-02)
Pedido do usuário: o valor já é revisado na aba "Em aberto há mais de 3 meses". Coluna removida do thead e de `montarLinhasConta()`, com a chave de ordenação `total_vencido` (um `?ordem=total_vencido` antigo na URL é ignorado); `colspan` da linha expandida e das mensagens de 8 para 7. A aba própria continua com a coluna dela.
Só HTML/JS: não exige reiniciar o `runserver`.
### Rodada 67 — Sai "Saldo inicial", "Saldo residual" vira "Saldo final" (2026-10-02)
Pedido do usuário, na tabela de fornecedores: coluna "Saldo inicial" removida (thead, `montarLinhasConta()` e a chave de ordenação `saldo_inicial`) e "Saldo residual" renomeada para "Saldo final" (mesmo valor, `saldo_final`, e mesma chave de ordenação). `colspan` de 7 para 6. Só a tabela da tela: XLSX e relatório não mudam.
Só HTML/JS: não exige reiniciar o `runserver`.
### Rodada 68 — Situação "Parcial" (2026-10-02)
Pedido do usuário: conta sem alerta, mas com saldo de pagamento parcial ainda a pagar (vínculo manual que segue para o mês seguinte), aparecia como "Conciliada"; deve ser "Parcial".
- `alertas.py`: `STATUS_PARCIAL = "parcial"`, entre `atencao` e `conciliada`: sem alerta e com algum vínculo `parcial` de `diferenca` > 0. Crítico e Atenção continuam prevalecendo.
- Rótulo "Parcial" em `_CONCILIACAO_ROTULO_STATUS` (views.py: payload, XLSX e `resumo.por_status`) e `ROTULO_STATUS` (`relatorio.py`); na tela, `PID_CONC_STATUS.parcial` (rank 2, etiqueta azul `.status-pill--parcial`); no relatório, `.cfr-pill--parcial` (roxo claro) e "N parciais" no card de situação.
- Efeitos: Parcial não recebe validação automática (só `conciliada`; na prática já não recebia, porque vínculo parcial é manual); no relatório, Parcial validada não entra em "contas validadas com pendência" e a ordem do anexo passou a ser crítica, atenção, parcial, conciliada.
- Na data da mudança, 4 contas (todas da conciliação #24, nenhuma validada) tinham vínculo parcial em aberto, entre elas a ROZINI (5449166), o caso do usuário. O `resumo` gravado de cada conciliação é recalculado ao abri-la.
Mexe em alertas.py/views.py/relatorio.py: exige reiniciar o `runserver`.
Na primeira subida, o usuário teve `KeyError: 'parcial'` em `_conciliacao_calcula_resumo()`: o servidor recarregou no meio das edições (alertas.py novo, views.py antigo, sem "Parcial" em `_CONCILIACAO_ROTULO_STATUS`). Resolvido só reiniciando; com o código final, a conciliação #24 calcula 226 conciliadas, 3 parciais, 30 em atenção e 1 crítica.
- Detalhe do vínculo (botão "i", `pidConcAbreDetalheVinculo()`), pedido do usuário na sequência: pagamento parcial com saldo mostra **"Saldo a pagar R$ X"** em destaque dourado (`.conc-vinculo-detalhe__saldo`, à direita dos totais, a partir de `vinculo.diferenca`), no lugar da frase "Créditos R$ X maiores que os débitos (em aberto)". Os demais vínculos continuam com a frase de diferença.
### Rodada 69 — Linha vinculada do razão mais resumida (2026-10-02)
Pedido do usuário, na linha de lançamento já vinculado do razão expandido: "Vinculado #10" virou só **"#10"**, e o nome do tipo ("Valor exato", "Pagamento parcial"...) virou a **proporção créditos:débitos** do vínculo ("1:1", "4:1" = 4 créditos para 1 débito; contada sobre `conta.lancamentos` por `vinculo_id`, saldo anterior incluído), seguida de "automático"/"manual" como antes. O nome do tipo e a proporção por extenso ficam no balão (`data-conc-tooltip`). O modal do "i" e o bloco de pagamentos parciais não mudam.
Só JS: não exige reiniciar o `runserver`.
### Rodada 70 — Colunas "Saldo" e "Em aberto" do razão expandido (2026-10-02)
Pedido do usuário, em `linhaRazaoHtml()`:
- **Em aberto**: só a linha "Saldo a pagar do vínculo #N" (saldo de pagamento parcial, para validação posterior) mostra valor. O lançamento sem vínculo passou a mostrar "—" (antes repetia o próprio valor); o valor em aberto dele continua no fato/sugestão da coluna Situação, nos totais e na lista separada de não conciliados (`linhaLancamentoHtml()`, sem mudança).
- **Saldo** (do Questor): "—" nas linhas de vínculo fechado; continua no lançamento sem vínculo e nas linhas de vínculo `parcial` com saldo a pagar.
Só JS: não exige reiniciar o `runserver`.
### Rodada 71 — Relatório como terceira aba do detalhe (2026-10-02)
Pedido do usuário: o relatório de análise fazer parte da própria tela, como uma terceira aba ("Relatório", depois de "Conciliação" e "Em aberto há mais de 3 meses").
- Escolha da implementação: o mesmo HTML do relatório num `<iframe>` (`#conc-relatorio-frame`), não uma reescrita no tema do Portal, para não haver duas versões do relatório a manter e manter a marca do escritório e a impressão.
- `relatorio` (views.py) passa a responder `X-Frame-Options: SAMEORIGIN` (o `XFrameOptionsMiddleware` só define o cabeçalho quando ele não existe; o padrão é DENY).
- Tela: o iframe é recarregado a cada abertura da aba (`carregarRelatorio()`) e pelo botão "Atualizar relatório"; a altura acompanha o conteúdo (`ResizeObserver` no body do iframe), para não haver rolagem dupla; `?aba=relatorio` no hash. O "Imprimir" de dentro do relatório imprime só o relatório (o Ctrl+P da página imprimiria a tela inteira).
- O botão "Gerar relatório" (nova aba) continua.
Mexe em views.py: exige reiniciar o `runserver`.
### Rodada 72 — Relatório refeito no visual do Portal, só dentro da aba (2026-10-02)
Na sequência da rodada 71, o usuário decidiu: o relatório é só para validação do contador dentro do Portal, nada vai ao cliente, então não precisa de documento separado. Escolhas confirmadas com ele: refazer no visual do Portal (em vez do iframe) e manter a impressão.
- `templates/conciliacao-fornecedores-relatorio.html` reescrito como **trecho de HTML** (sem `<html>`, `<head>`, `<script>`, fontes da web nem logo do escritório), com classes `.conc-rel-*` (novas em `conciliacao-fornecedores.css`) sobre os tokens de tema, então acompanha claro/escuro. Mesmas seções e dados de antes (parecer, cards, riscos, decisões, o que falta decidir, anexo); saiu só a linha "Processado por / Arquivo" do parecer, que já está no cabeçalho do detalhe.
- View `relatorio`: sem `logo_data_uri` e sem o `X-Frame-Options: SAMEORIGIN` da rodada 71. `_logo_relatorio_data_uri()` continua, porque o Relatório Contábil usa.
- Tela: o iframe saiu; `carregarRelatorio()` busca o trecho com `fetch` e o insere em `#conc-relatorio-conteudo` (ignora a resposta se o usuário trocou de conciliação ou pediu outro relatório no meio; limpa o de outra conciliação antes de carregar). Botão **"Gerar relatório" removido** do cabeçalho do detalhe.
- Barra da aba: "Situação em ...", "Incluir anexo na impressão", "Atualizar relatório" e **"Imprimir"**. A impressão clona o relatório num `#conc-impressao` direto no body; o `@media print` esconde o resto do Portal, redefine os tokens com uma paleta clara fixa só dentro dele e mantém as regras de A4 compacto da rodada 47 (blocos abertos, anexo opcional, linha de tabela/cabeçalho de bloco/cards sem quebra, cabeçalho da tabela repetido). Um cabeçalho só de impressão traz empresa, período, data-base e data de geração.
Mexe em views.py: exige reiniciar o `runserver`. Conferido: o trecho renderiza com a conciliação #24 (6 blocos de risco, ~480 linhas de tabela). Sem conferência visual nem de impressão neste ambiente.
### Rodada 73 — Confirmação ao validar/desvalidar quando a conta muda de chip (2026-10-02)
Pedido do usuário: ao validar ou desvalidar, o fornecedor sumia da lista (ia para outro chip) sem aviso. Agora `confirmaMudancaDeChip()` pergunta antes (`pidConfirm`), citando o chip de destino ("...sairá desta lista. Ela passa a aparecer em "Saldo em aberto" (e em "Validados")" / "...Ela volta para "Pendentes""), no checkbox da linha e no botão "Marcar como validada"/"Remover validação" do detalhe. Só pergunta quando a conta sai da lista atual (`contaPassaFiltro()` com a validação nova); cancelar devolve o checkbox ao estado anterior. O destino é previsível porque validar não muda saldo nem vínculos. A validação da aba "Em aberto há mais de 3 meses" não mudou.
Só JS: não exige reiniciar o `runserver`.
### Rodada 74 — Ajuste do vínculo: parcelamento, juros ou desconto (2026-10-02)
Pedido do usuário: ao vincular um pagamento parcial, escolher entre parcelamento e juros/desconto (juros quando há mais pagamento que títulos, desconto quando há mais títulos que pagamentos), e uma coluna nos validados com um símbolo por situação, filtrável.
- **Escolha**: o confirm "Pagamento parcial" virou o modal "Diferença no vínculo" (`pidConcEscolheAjuste()`), com **Parcelamento** (como antes: vínculo `parcial`, título em aberto pelo saldo) e **Desconto** (vínculo `manual` que fecha, com a diferença). Aparece no "Vincular selecionados" e no "Adicionar ao vínculo" quando os títulos passam dos pagamentos além da tolerância. Servidor: `fechar` (opcional, padrão falso) em `ConciliacaoFornecedorVincularSerializer`, repassado a `_conciliacao_tipo_vinculo_manual()`, que não vira `parcial` com ele. Pago a maior (juros) e diferença dentro da tolerância continuam fechando direto, com o confirm reescrito ("será registrada como juros/desconto").
- **Interpretação da implementação**: a classificação não é gravada, é derivada de tipo + diferença (`pidConcAjusteVinculo()`): `parcial` > 0 = parcelamento; `manual`/`tolerancia` < 0 = juros, > 0 = desconto. Com isso, vínculos já existentes também ganham ícone, e diferença dentro da tolerância conta como juros/desconto. Parcelamento quitado por inteiro (diferença zero) deixa de ter ícone. Sem migração.
- **Coluna "Ajuste"** na tabela de fornecedores (depois de "Saldo final"), só nos chips de validadas: um ícone por ajuste presente (`pidConcAjustesHtml()`; parcelamento azul com calendário, juros vermelho com seta para cima, desconto verde com seta para baixo), com o valor no balão. **Filtro "Ajuste:"** ao lado dos chips (Todos + um chip compacto por ajuste, ícone e quantidade dentro do chip atual, nome no balão, porque a barra de filtros é fixa e de uma linha), `?ajuste=` na URL; zera ao trocar de chip; filtro de card/etiqueta o ignora. `colspan` de 6 para 7.
- Na data, a conciliação #24 tinha 3 vínculos de parcelamento (contas ainda não validadas) e nenhum de juros/desconto.
Mexe em views.py/serializers.py: exige reiniciar o `runserver`.
### Rodada 75 — Sugestões de parcela mais visuais, com a sequência (2026-10-02)
Pedido do usuário: na sugestão do título (crédito), mostrar os pagamentos de forma mais visual, com o código, e melhorar a sugestão de parcela.
- `ItemAnalise.sequencia` (novo, padrão vazio), preenchido em `_conciliacao_analisa()` (views.py).
- `_linha_item()` (alertas.py): "Seq. 37879 · 26/06/2026 · R$ 2.163,57[ · extra]" (saldo de pagamento parcial: "Saldo a pagar · ..."). Usado nas linhas da parcela, do parcelamento, da combinação exata e da quitação dos mais antigos (antes "26/06/2026 (R$ 2.163,57)").
- Parcela (lado do pagamento): "Possível parcela N de K do título:" / linha do título / "Parcela de R$ 2.163,56 em 4x, com R$ 0,01 a mais (arredondamento)." / "Vincule para confirmar." N = ordem entre os pagamentos reconhecidos como parcela daquele título. Parcelamento (lado do título): "Possível parcelamento deste título em N pagamento(s):" / uma linha por pagamento com "parcela i de K" / "Pagos R$ X de R$ Y; R$ Z ainda em aberto."
- Tela: sugestão em várias linhas abre o balão sempre (antes só quando cortada) e é montada por `pidConcSugestaoEstruturadaHtml()` (classe `.conc-tooltip--sugestao`, até 560px): cabeçalho em negrito, lançamentos em colunas com a sequência em destaque, notas em cinza. Na célula e no XLSX continua numa linha (`pidConcTextoEmLinha()`/`texto_em_linha()`).
- Na sequência, pedido do usuário: o código no balão leva a **cor da natureza** do lançamento citado, como no razão (`.conc-natureza--D` coral para pagamento, `--C` roxo para título, `--saldo` dourado para o saldo a pagar). Os citados são sempre do lado oposto ao da linha da sugestão, então a natureza sai da própria linha (tem `.conc-natureza--D` = os citados são títulos; senão, pagamentos).
- Ajustes seguintes, pedido do usuário: o valor de cada lançamento no balão ficou do tamanho da data (a classe `.conc-tooltip__valor` colidia com o percentual grande do balão da barra de validação; virou `.conc-tooltip__lanc-valor`), e a última linha do parcelamento passou a ser só o que falta ("R$ 4.327,10 ainda em aberto."; sem saldo, "Os pagamentos quitam o título."), sem o "Pagos R$ X de R$ Y".
- Conferido na conciliação #24: 101 sugestões de parcela/soma com o texto novo (ex.: ROZINI, "Possível parcela 1 de 4 do título: Seq. 35359 · 28/05/2026 · R$ 8.654,22").
Mexe em alertas.py/views.py: exige reiniciar o `runserver`.
### Rodada 76 — Vínculo com desconto/juros aparece no razão como o parcelamento (2026-10-02)
Pedido do usuário: ao escolher "Desconto" (rodada 74), o vínculo fechava e sumia do razão expandido (que começa na primeira pendência), sem mostrar a diferença. Caso real: ROZINI, vínculo manual 19102 com desconto, depois desfeito e refeito como parcelamento (19103).
- Vínculos `manual` com ajuste desconto ou juros (`pidConcAjusteVinculo()`) entram no início do razão, como os de parcelamento (`ajusteFechado` em `montarDetalheConta()`).
- Logo abaixo do último crédito do vínculo (mesma regra da linha do saldo a pagar), uma **linha da diferença** (`linhaAjusteHtml()`): "Desconto do vínculo #N" / "Juros do vínculo #N", "Pagos R$ X de R$ Y em títulos", natureza "Desconto" (verde) ou "Juros" (vermelho), valor = diferença, sem seleção, saldo e em aberto "—" (o vínculo está fechado), situação "Vínculo fechado com desconto (pago a menor)." / "...com juros/multa (pago a maior).". Fundo verde/vermelho discreto, borda tracejada, destaca junto com o vínculo no hover e não é escondida por "Ocultar conciliados" (igual à do saldo a pagar).
- Só vínculos `manual` (os automáticos antigos de `tolerancia` não ganham a linha). Interpretação: o pedido dizia "especificando que são juros" para o desconto; a linha usa o nome certo de cada caso (desconto quando os títulos passam dos pagamentos, juros quando os pagamentos passam dos títulos). Na data, a conciliação #24 tinha 2 vínculos manuais com desconto (CASIO R$ 5.065,00 e EBAZAR R$ 103,99).
Só JS/CSS: não exige reiniciar o `runserver`.
### Rodada 77 — Clicar fora recolhe; copiar texto da linha do fornecedor (2026-10-02)
Pedidos do usuário:
- **Clicar fora recolhe** os fornecedores expandidos: listener de `click` no document (`abertas.clear()` + `renderContas()`), só na aba Conciliação e com alguma expandida. Não recolhe: clique dentro de `#conc-contas-body` (linha e expansão), em `.modal-overlay` ou `.conc-tooltip`, em elemento que a ação do clique já tirou da página (re-render depois de vincular/desfazer) ou que termina uma seleção de texto. Rascunhos de observação sobrevivem (já sobreviviam ao recolher).
- **Copiar texto** da linha do fornecedor (antes, o clique expandia e o re-render desfazia a seleção): arrastar para selecionar não expande (mesma regra das linhas de lançamento), e o clique simples espera 200 ms antes de expandir, cancelado se vier um segundo clique (duplo/triplo clique selecionam palavra/linha sem expandir).
Só JS: não exige reiniciar o `runserver`.
### Rodada 78 — Desfazer vínculo remove a validação da conta (2026-10-02)
Pedido do usuário: desfazer um vínculo de uma conta já validada deve desmarcar a validação (caso real: ROZINI, validada, vínculo de parcelamento #10).
- `ConciliacaoFornecedorVinculoViewSet.destroy` (views.py): na mesma transação do desfazer, se a conta estava validada (por pessoa ou automática), zera `validada`/`validada_por`/`validada_em`; a observação fica (mesmo critério da atualização do razão). A resposta (`{conta, resumo}`) já volta com a conta "a validar", então ela sai do chip de validadas para "Pendentes" na tela.
- Tela: a pergunta "Desfazer vínculo" avisa quando a conta está validada ("A conta está validada: a validação será removida e ela volta para "Pendentes".").
- Só o desfazer. Vincular, acrescentar a um vínculo e a validação dos títulos antigos (aba própria) não mexem na validação.
Mexe em views.py: exige reiniciar o `runserver`.
### Rodada 79 — Situação "Atenção" vira "A validar" (2026-10-02)
Pedido do usuário: rótulo da situação `atencao` passa de "Atenção" para **"A validar"** na tela (`PID_CONC_STATUS`), no XLSX (`_CONCILIACAO_ROTULO_STATUS`, views.py) e no relatório (`ROTULO_STATUS` em `relatorio.py`; "N a validar" no card e "conta em situação A validar ou Crítico" no subtítulo das validadas com pendência). Chave interna (`atencao`), severidade "Média" dos alertas e cor da etiqueta sem mudança.
Ponto de atenção levado ao usuário: a situação é calculada pelos alertas e independe da validação, então uma conta validada com alerta médio/baixo mostra a etiqueta "A validar" ao lado do checkbox marcado.
Mexe em views.py/relatorio.py: exige reiniciar o `runserver`.
### Rodada 80 — Clique com texto já selecionado volta a expandir (2026-10-02)
Bug da rodada 77, apontado pelo usuário: com um texto selecionado, clicar na linha do fornecedor não expandia; o primeiro clique só desfazia a seleção. Causa: o critério era "há texto selecionado no momento do clique", e o navegador só desfaz uma seleção existente depois do clique. Troca de critério para **arraste** (`pidConcFoiArraste()`: o mouse andou mais de 4 px entre o mousedown, registrado em captura no document, e o clique). Vale para a linha do fornecedor, para o "clicar fora recolhe" e para o clique que marca/desmarca a linha de lançamento do razão (que tinha a mesma checagem). O duplo/triplo clique continua sem expandir.
Só JS: não exige reiniciar o `runserver`.
### Rodada 81 — Filtro de ajuste no cabeçalho da coluna (2026-10-02)
Pedido do usuário: o filtro "Ajuste:" (chips ao lado dos chips de grupo, rodada 74) passar para a própria coluna "Ajuste", e a dúvida se três ícones cabem na coluna.
- O cabeçalho virou o botão **"Ajuste ▾"** (`#conc-ajuste-th`), que abre um menu (`.conc-ajuste-menu`, fixo no body como o balão, porque o cabeçalho é sticky) com Todos, Parcelamento, Juros e Desconto, cada um com ícone e quantidade dentro do chip atual. Fecha ao escolher, ao clicar fora, com Esc e ao rolar. Com filtro ativo, o cabeçalho mostra o ícone do ajuste filtrado e fica na cor de destaque. O clique no cabeçalho não conta como "clicar fora" para recolher os fornecedores.
- Os chips de ajuste da barra de filtros saíram (HTML, JS e CSS `.conc-ajuste-filtro`/`.conc-chip--ajuste`).
- Coluna de 90 para 100px: três ícones (22px + 4px entre eles, ~74px) cabem com folga.
Só HTML/JS/CSS: não exige reiniciar o `runserver`.
### Rodada 82 — Atualização por acréscimo do período novo, com conferência de saldos (2026-10-02)
Pedido do usuário: anexar só o mês novo apagava o trabalho (a atualização era sempre substituição: lançamentos antes do arquivo viravam um saldo anterior sem composição, todos os vínculos eram desfeitos, validações retiradas e contas ausentes do arquivo excluídas). E: "o ideal é que ele valide o saldo inicial do novo razão, que deve bater com o saldo final do que consta atualmente".
- **Modo escolhido pela data de início do arquivo** (`_conciliacao_inicio_acrescimo()`, views.py): começa depois do início da conciliação = **acréscimo**; no início ou antes = **substituição** (a de antes, sem mudança). Arquivo que deixa buraco depois do fim atual é recusado ("falta o razão de ... a ...").
- **Conferência de saldos** (`_conciliacao_confere_saldos_acrescimo()`), antes de gravar: saldo inicial de cada conta do arquivo = saldo inicial da conta + lançamentos antes do início do arquivo (no caso comum, o saldo final). Diferença recusa a atualização inteira, listando até 15 contas ("arquivo R$ X C, conciliação R$ Y C"). O erro do modal passou a respeitar quebras de linha.
- **Acréscimo** em `_conciliacao_atualiza_conta(..., inicio_acrescimo)`: o prefixo (lançamentos antes do início e o saldo anterior original) fica intacto; só o resto é comparado com o arquivo; o "Saldo anterior" do arquivo não vira lançamento; os novos ganham `ordem` depois do prefixo; `saldo_inicial` não muda; o motor roda sobre a conta inteira com os vinculados fixos. Conta ausente do arquivo fica como está (`contas_sem_movimento`); conta nova entra inteira. Período: início da conciliação até o "Razão até" novo. A regra de validação continua: conta validada por pessoa que recebeu lançamento volta a "a validar".
- Tela: texto do modal "Atualizar razão" explica os dois modos; o resumo depois da atualização diz qual foi aplicado e quantos fornecedores ficaram sem movimento.
- **Testes** (transação desfeita, nada gravado) contra a conciliação #24: (A) outubro simulado, 114 contas com saldo: 114 lançamentos novos, 0 removidos, 632 vínculos e 22 manuais/parciais mantidos (ROZINI com o parcelamento de R$ 4.327,10), 146 contas sem movimento mantidas, 13 validações retiradas; (B) razão da própria conciliação a partir de 01/08: nada muda; (C) uma conta com R$ 10 a mais: recusado apontando só ela; (D) começo em 05/10: recusado pelo buraco. O arquivo real "Atualizado 09-2026" (só saldos, sem lançamentos, 110 contas) foi recusado: 42 contas com saldo em 31/08 diferente do da conciliação (ex.: ROZINI R$ 2.163,56 x R$ 35.712,21), sinal de que foi exportado depois de mudanças no Questor.
Mexe em views.py: exige reiniciar o `runserver`.
Depois da rodada 82, a pedido do usuário (para refazer a conciliação e testar o acréscimo com a competência 09/2026), a conciliação #24 (empresa 2017) foi excluída via shell: 260 contas, 2.609 lançamentos, 632 vínculos e 36 registros de histórico de vinculações. Era a única conciliação no banco; nenhum arquivo envolvido.
### Rodada 83 — Saldo anterior duplicado na exportação do Questor (2026-10-02)
Bug apontado pelo usuário: a CASIO (5198422) aparecia com saldo final R$ 15.586,75 C na conciliação #25 (empresa 2017, arquivo "Teste Exportação - Fornecedores - 2017 - Atualizado - teste.xlsx", criada pelo usuário depois da exclusão da #24), e o saldo certo é R$ 1.828,74 C.
- Causa: nesse arquivo, o Questor exportou o "Saldo anterior" datado de 31/03/2025 (R$ 13.758,01 C na CASIO) **seguido** dos lançamentos de 31/12/2024 a 27/03/2025, cuja soma é exatamente esse saldo anterior. O parser somava os dois. A coluna Saldo do próprio Questor também partia do saldo anterior, então a conferência "saldo recalculado x arquivo" não acusava nada.
- Medição: 82 das 376 contas do arquivo têm lançamentos até a data do saldo anterior, e em todas a soma deles é igual ao saldo anterior. Descontando o saldo anterior nessas contas, o saldo final das 376 bate com o saldo anterior em 31/08/2026 do arquivo "Atualizado 09-2026". Os outros arquivos de teste (2017 antigos e 1751) não têm o caso.
- Correção em `parser.py` (`_corrige_saldo_anterior_duplicado()`, chamada para cada conta ao fim da leitura): soma igual = saldo anterior zerado, data dele passa para a véspera do primeiro lançamento (o período passa a começar em 31/12/2024) e cada `saldo_apos` é descontado do valor duplicado; soma diferente = `ArquivoRazaoInvalidoError` pedindo nova exportação (não há como saber qual dos dois vale).
- A conciliação #25, já gravada com os saldos errados, não é corrigida sozinha: precisa ser refeita (ou atualizada por substituição com o mesmo arquivo).
Mexe em parser.py: exige reiniciar o `runserver`.
Depois da rodada 83, a pedido do usuário (para refazer com o parser corrigido), a conciliação #25 (empresa 2017, criada com o saldo anterior duplicado) foi excluída via shell. Era a única conciliação no banco; nenhum arquivo envolvido.

File diff suppressed because one or more lines are too long

View File

@ -59,6 +59,9 @@ SEVERIDADE_MEDIA = "media"
SEVERIDADE_BAIXA = "baixa" SEVERIDADE_BAIXA = "baixa"
STATUS_CONCILIADA = "conciliada" STATUS_CONCILIADA = "conciliada"
# Sem alerta, mas com saldo de pagamento parcial ainda a pagar (vínculo
# manual que segue para os meses seguintes); pedido do usuário.
STATUS_PARCIAL = "parcial"
STATUS_ATENCAO = "atencao" STATUS_ATENCAO = "atencao"
STATUS_DIVERGENTE = "divergente" STATUS_DIVERGENTE = "divergente"
@ -76,6 +79,7 @@ class ItemAnalise:
valor: Decimal valor: Decimal
vinculo_id: int | None vinculo_id: int | None
descricao: str # histórico, para citar o título abatido descricao: str # histórico, para citar o título abatido
sequencia: str = "" # código do lançamento no Questor, citado nas sugestões
@dataclass @dataclass
@ -242,6 +246,18 @@ def _rotulo_pagamento(debito: ItemAnalise) -> str:
return f"{debito.data:%d/%m/%Y} ({_moeda(debito.valor)})" return f"{debito.data:%d/%m/%Y} ({_moeda(debito.valor)})"
def _linha_item(item: ItemAnalise, extra: str = "") -> str:
"""Uma linha de lançamento numa sugestão em várias linhas: "Seq. 37879 ·
26/06/2026 · R$ 2.163,57" (pedido do usuário: ver o código de cada
lançamento). O balão da tela monta cada linha "Seq. ..." em colunas,
separando por " · "; o XLSX junta tudo numa linha (`texto_em_linha`)."""
codigo = "Saldo a pagar" if _eh_saldo_parcial(item) else f"Seq. {item.sequencia or '—'}"
partes = [codigo, f"{item.data:%d/%m/%Y}", _moeda(item.valor)]
if extra:
partes.append(extra)
return " · ".join(partes)
def _natureza_diferenca(diferenca: Decimal) -> str: def _natureza_diferenca(diferenca: Decimal) -> str:
"""`diferenca` = título - pagamento (mesma convenção do vínculo).""" """`diferenca` = título - pagamento (mesma convenção do vínculo)."""
return "a menos (possível desconto)" if diferenca > 0 else "a mais (possível juros/multa)" return "a menos (possível desconto)" if diferenca > 0 else "a mais (possível juros/multa)"
@ -293,11 +309,10 @@ def _analisa_pendentes(
if alvo.natureza == "C": if alvo.natureza == "C":
nome_grupo, nome_outro = "pagamentos", "pagamento(s)" nome_grupo, nome_outro = "pagamentos", "pagamento(s)"
rotulo_alvo = _ref_titulo(alvo).removeprefix("do ") rotulo_alvo = _ref_titulo(alvo).removeprefix("do ")
rotulos = [_rotulo_pagamento(d) for d in grupo]
else: else:
nome_grupo, nome_outro = "títulos", "título(s)" nome_grupo, nome_outro = "títulos", "título(s)"
rotulo_alvo = f"pagamento de {_rotulo_pagamento(alvo)}" rotulo_alvo = f"pagamento de {_rotulo_pagamento(alvo)}"
rotulos = [_rotulo_titulo(c) for c in grupo] rotulos = [_linha_item(i) for i in grupo]
sugestao_combinacao[alvo.id] = ( sugestao_combinacao[alvo.id] = (
"\n".join([f"Soma exata de {len(grupo)} {nome_grupo}:", *rotulos, "Vincule para confirmar."]), "\n".join([f"Soma exata de {len(grupo)} {nome_grupo}:", *rotulos, "Vincule para confirmar."]),
[i.id for i in grupo], [i.id for i in grupo],
@ -347,6 +362,7 @@ def _analisa_pendentes(
# Pagamentos que parecem parcela de cada título (base concreta da sugestão # Pagamentos que parecem parcela de cada título (base concreta da sugestão
# do lado do título, ao contrário da ordem de antiguidade). # do lado do título, ao contrário da ordem de antiguidade).
parcelas_do_titulo: dict[int, list[int]] = {c.id: [] for c in creditos} parcelas_do_titulo: dict[int, list[int]] = {c.id: [] for c in creditos}
qtd_parcelas_do_pagamento: dict[int, int] = {} # id do débito -> "em Nx"
# Títulos de uma quitação exata dos mais antigos: id -> (texto, relacionados) # Títulos de uma quitação exata dos mais antigos: id -> (texto, relacionados)
quitacao_do_titulo: dict[int, tuple[str, list[int]]] = {} quitacao_do_titulo: dict[int, tuple[str, list[int]]] = {}
@ -426,18 +442,20 @@ def _analisa_pendentes(
if escolhida and usados == [parcela_de.id]: if escolhida and usados == [parcela_de.id]:
qtd_parcelas, referencia, diferenca = escolhida[1] qtd_parcelas, referencia, diferenca = escolhida[1]
parcelas_do_titulo[parcela_de.id].append(debito.id) parcelas_do_titulo[parcela_de.id].append(debito.id)
# Quantidade de parcelas no texto (pedido do usuário). qtd_parcelas_do_pagamento[debito.id] = qtd_parcelas
if diferenca == 0: # Em linhas (pedido do usuário: mais visual, com o código): o
sugestao = ( # título, depois a parcela (número entre as reconhecidas deste
f"Possível parcela {_ref_titulo(parcela_de)}, em {qtd_parcelas}x de " # título, quantidade e valor; arredondamento, se houver).
f"{_moeda(debito.valor)}. Vincule para confirmar." if _eh_saldo_parcial(parcela_de):
) cabecalho = "Possível parcela do saldo a pagar:"
else: else:
numero = len(parcelas_do_titulo[parcela_de.id])
cabecalho = f"Possível parcela {numero} de {qtd_parcelas} do título:"
detalhe_parcela = f"Parcela de {_moeda(referencia)} em {qtd_parcelas}x"
if diferenca:
sentido = "a mais" if diferenca > 0 else "a menos" sentido = "a mais" if diferenca > 0 else "a menos"
sugestao = ( detalhe_parcela += f", com {_moeda(diferenca)} {sentido} (arredondamento)"
f"Possível parcela {_ref_titulo(parcela_de)}, em {qtd_parcelas}x de " sugestao = "\n".join([cabecalho, _linha_item(parcela_de), f"{detalhe_parcela}.", "Vincule para confirmar."])
f"{_moeda(referencia)}, com {_moeda(diferenca)} {sentido} (arredondamento). Vincule para confirmar."
)
elif ( elif (
len(titulos) >= 2 len(titulos) >= 2
and disponivel_usado == debito.valor and disponivel_usado == debito.valor
@ -451,7 +469,7 @@ def _analisa_pendentes(
sugestao = "\n".join( sugestao = "\n".join(
[ [
f"Soma exata dos {len(titulos)} títulos mais antigos em aberto:", f"Soma exata dos {len(titulos)} títulos mais antigos em aberto:",
*(_rotulo_titulo(c) for c in titulos), *(_linha_item(c) for c in titulos),
"Vincule para confirmar.", "Vincule para confirmar.",
] ]
) )
@ -511,9 +529,19 @@ def _analisa_pendentes(
pagos = parcelas_do_titulo[credito.id] pagos = parcelas_do_titulo[credito.id]
soma = sum((por_id_debito[i].valor for i in pagos), Decimal("0")) soma = sum((por_id_debito[i].valor for i in pagos), Decimal("0"))
alvo = "deste saldo" if _eh_saldo_parcial(credito) else "deste título" alvo = "deste saldo" if _eh_saldo_parcial(credito) else "deste título"
sugestao = f"Possível parcelamento: {len(pagos)} pagamento(s) de parcela {alvo}, somando {_moeda(soma)}" # Em linhas, um pagamento por linha com o código (pedido do
# usuário), e no fim quanto foi pago e quanto falta.
linhas = [f"Possível parcelamento {alvo} em {len(pagos)} pagamento(s):"]
for numero, debito_id in enumerate(pagos, start=1):
debito = por_id_debito[debito_id]
extra = "" if _eh_saldo_parcial(credito) else f"parcela {numero} de {qtd_parcelas_do_pagamento[debito_id]}"
linhas.append(_linha_item(debito, extra))
# Abaixo, só o que falta (pedido do usuário; o pago já está nas linhas).
restante_titulo = credito.valor - soma restante_titulo = credito.valor - soma
sugestao += f"; {_moeda(restante_titulo)} ainda em aberto." if restante_titulo > 0 else "." linhas.append(
f"{_moeda(restante_titulo)} ainda em aberto." if restante_titulo > 0 else "Os pagamentos quitam o título."
)
sugestao = "\n".join(linhas)
else: else:
pagos = [] pagos = []
sugestao = "" sugestao = ""
@ -629,6 +657,8 @@ def analisa_conta(
status = STATUS_DIVERGENTE status = STATUS_DIVERGENTE
elif severidades: elif severidades:
status = STATUS_ATENCAO status = STATUS_ATENCAO
elif any(v.tipo == TIPO_PARCIAL and v.diferenca > 0 for v in vinculos):
status = STATUS_PARCIAL
else: else:
status = STATUS_CONCILIADA status = STATUS_CONCILIADA

View File

@ -114,6 +114,39 @@ class RazaoExtraido:
return max(datas) if datas else None return max(datas) if datas else None
def _corrige_saldo_anterior_duplicado(conta: ContaExtraida) -> None:
"""Exportação do Questor com o "Saldo anterior" datado **depois** de
lançamentos que vêm listados logo abaixo dele (caso real, empresa 2017:
saldo anterior em 31/03/2025 seguido dos lançamentos de 31/12/2024 a
27/03/2025). O saldo anterior já é a soma desses lançamentos, então
somá-lo de novo dobrava o saldo (CASIO: R$ 15.586,75 em vez de
R$ 1.828,74), inclusive na coluna Saldo impressa pelo Questor, que também
parte dele. Nas 82 contas do arquivo com esse caso, a soma bateu exatamente
com o saldo anterior, e os saldos corrigidos das 376 contas bateram com o
saldo anterior do mês seguinte. Por isso: soma igual = saldo anterior
duplicado, vira zero, a data dele passa para a véspera do primeiro
lançamento e a coluna Saldo é corrigida; soma diferente = arquivo que não
dá para interpretar com segurança, recusado (rodada 83 do CHANGELOG)."""
if conta.data_saldo_inicial is None:
return
antes = [lanc for lanc in conta.lancamentos if lanc.data <= conta.data_saldo_inicial]
if not antes:
return
soma = sum((lanc.valor if lanc.natureza == "C" else -lanc.valor for lanc in antes), Decimal("0"))
if soma != conta.saldo_inicial:
raise ArquivoRazaoInvalidoError(
f"Conta {conta.conta_numero} ({conta.fornecedor}): o \"Saldo anterior\" está datado de "
f"{conta.data_saldo_inicial:%d/%m/%Y}, mas há lançamentos até essa data que não o explicam. "
"Exporte o razão de novo, com a data inicial do período depois do saldo anterior."
)
duplicado = conta.saldo_inicial
conta.saldo_inicial = Decimal("0")
conta.data_saldo_inicial = min(lanc.data for lanc in conta.lancamentos) - timedelta(days=1)
for lanc in conta.lancamentos:
if lanc.saldo_apos is not None:
lanc.saldo_apos -= duplicado
def _sem_acento(texto: str) -> str: def _sem_acento(texto: str) -> str:
normalizado = unicodedata.normalize("NFKD", texto) normalizado = unicodedata.normalize("NFKD", texto)
return "".join(c for c in normalizado if not unicodedata.combining(c)).upper().strip() return "".join(c for c in normalizado if not unicodedata.combining(c)).upper().strip()
@ -285,6 +318,9 @@ def extrai_razao(conteudo: bytes, nome_arquivo: str) -> RazaoExtraido:
if not contas: if not contas:
raise ArquivoRazaoInvalidoError("Nenhuma conta encontrada no arquivo (linhas \"Conta: ...\").") raise ArquivoRazaoInvalidoError("Nenhuma conta encontrada no arquivo (linhas \"Conta: ...\").")
for conta in contas:
_corrige_saldo_anterior_duplicado(conta)
datas = [lanc.data for conta in contas for lanc in conta.lancamentos] datas = [lanc.data for conta in contas for lanc in conta.lancamentos]
# O início sai da linha "Saldo anterior" (datada do dia anterior ao # O início sai da linha "Saldo anterior" (datada do dia anterior ao
# período), não do primeiro lançamento: o período pode começar sem # período), não do primeiro lançamento: o período pode começar sem

View File

@ -43,8 +43,9 @@ SEVERIDADES = (alertas.SEVERIDADE_ALTA, alertas.SEVERIDADE_MEDIA, alertas.SEVERI
ROTULO_SEVERIDADE = {alertas.SEVERIDADE_ALTA: "Alto", alertas.SEVERIDADE_MEDIA: "Médio", alertas.SEVERIDADE_BAIXA: "Baixo"} ROTULO_SEVERIDADE = {alertas.SEVERIDADE_ALTA: "Alto", alertas.SEVERIDADE_MEDIA: "Médio", alertas.SEVERIDADE_BAIXA: "Baixo"}
ROTULO_STATUS = { ROTULO_STATUS = {
alertas.STATUS_CONCILIADA: "Conciliada", alertas.STATUS_CONCILIADA: "Conciliada",
alertas.STATUS_ATENCAO: "Atenção", alertas.STATUS_PARCIAL: "Parcial",
alertas.STATUS_DIVERGENTE: "Divergente", alertas.STATUS_ATENCAO: "A validar",
alertas.STATUS_DIVERGENTE: "Crítico",
} }
RISCO_GERAL_ALTO = "alto" RISCO_GERAL_ALTO = "alto"
RISCO_GERAL_MODERADO = "moderado" RISCO_GERAL_MODERADO = "moderado"
@ -462,7 +463,7 @@ def monta_relatorio(contas: list[ContaRelatorio], data_base: date, processado_po
( (
"validada_sem_justificativa", "validada_sem_justificativa",
alertas.SEVERIDADE_ALTA, alertas.SEVERIDADE_ALTA,
"Conta validada com divergência e sem observação", "Conta em situação Crítico validada sem observação",
"A decisão foi tomada, mas não há justificativa registrada para quem revisar depois.", "A decisão foi tomada, mas não há justificativa registrada para quem revisar depois.",
False, False,
), ),
@ -649,7 +650,9 @@ def monta_relatorio(contas: list[ContaRelatorio], data_base: date, processado_po
) )
# decisões # decisões
if conta.validada and conta.validada_por and analise.status != alertas.STATUS_CONCILIADA: # Parcial não é pendência de análise: o saldo a pagar já aparece em
# "Saldos de pagamento parcial".
if conta.validada and conta.validada_por and analise.status not in (alertas.STATUS_CONCILIADA, alertas.STATUS_PARCIAL):
validacoes.append( validacoes.append(
ValidacaoComPendencia( ValidacaoComPendencia(
conta.conta_numero, conta.conta_numero,
@ -781,8 +784,8 @@ def monta_relatorio(contas: list[ContaRelatorio], data_base: date, processado_po
if not sintese: if not sintese:
sintese.append("Nenhum ponto de risco pendente") sintese.append("Nenhum ponto de risco pendente")
# divergentes primeiro, depois atenção e conciliadas # críticas primeiro, depois atenção, parciais e conciliadas
ordem_status = (alertas.STATUS_DIVERGENTE, alertas.STATUS_ATENCAO, alertas.STATUS_CONCILIADA) ordem_status = (alertas.STATUS_DIVERGENTE, alertas.STATUS_ATENCAO, alertas.STATUS_PARCIAL, alertas.STATUS_CONCILIADA)
nao_validadas.sort(key=lambda c: (ordem_status.index(c.status), -c.valor)) nao_validadas.sort(key=lambda c: (ordem_status.index(c.status), -c.valor))
validacoes.sort(key=lambda v: (v.status != alertas.STATUS_DIVERGENTE, bool(v.observacao))) validacoes.sort(key=lambda v: (v.status != alertas.STATUS_DIVERGENTE, bool(v.observacao)))
ajustes.sort(key=lambda a: a.ultima_acao, reverse=True) ajustes.sort(key=lambda a: a.ultima_acao, reverse=True)

View File

@ -2966,6 +2966,10 @@ class ConciliacaoFornecedorVincularSerializer(serializers.Serializer):
# 2+ para criar um vínculo; 1 já basta para acrescentar a um existente # 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). # (a exigência de débito + crédito da criação fica na view).
lancamentos = serializers.ListField(child=serializers.IntegerField(), min_length=1) lancamentos = serializers.ListField(child=serializers.IntegerField(), min_length=1)
# Títulos maiores que os pagamentos além da tolerância: por padrão é
# pagamento parcial (parcelamento, o saldo continua em aberto); `fechar`
# = o contador escolheu "Desconto", o vínculo fecha com a diferença.
fechar = serializers.BooleanField(required=False, default=False)
class ConciliacaoFornecedorValidarSerializer(serializers.Serializer): class ConciliacaoFornecedorValidarSerializer(serializers.Serializer):

View File

@ -5378,8 +5378,9 @@ class ContabilAchadoDePaulaViewSet(ContabilAchadoViewSet):
_CONCILIACAO_PERMISSAO = ("utilitarios", "conciliacao-fornecedores") _CONCILIACAO_PERMISSAO = ("utilitarios", "conciliacao-fornecedores")
_CONCILIACAO_ROTULO_STATUS = { _CONCILIACAO_ROTULO_STATUS = {
conciliacao_alertas.STATUS_CONCILIADA: "Conciliada", conciliacao_alertas.STATUS_CONCILIADA: "Conciliada",
conciliacao_alertas.STATUS_ATENCAO: "Atenção", conciliacao_alertas.STATUS_PARCIAL: "Parcial",
conciliacao_alertas.STATUS_DIVERGENTE: "Divergente", conciliacao_alertas.STATUS_ATENCAO: "A validar",
conciliacao_alertas.STATUS_DIVERGENTE: "Crítico",
} }
_CONCILIACAO_ROTULO_SEVERIDADE = {"alta": "Alta", "media": "Média", "baixa": "Baixa"} _CONCILIACAO_ROTULO_SEVERIDADE = {"alta": "Alta", "media": "Média", "baixa": "Baixa"}
@ -5410,6 +5411,7 @@ def _conciliacao_analisa(
valor=lanc.valor, valor=lanc.valor,
vinculo_id=lanc.vinculo_id, vinculo_id=lanc.vinculo_id,
descricao=lanc.historico, descricao=lanc.historico,
sequencia=lanc.sequencia or "",
) )
for lanc in lancamentos for lanc in lancamentos
], ],
@ -5758,18 +5760,34 @@ def _conciliacao_atualiza_conta(
bloqueados: set[int], bloqueados: set[int],
usuario: Usuario, usuario: Usuario,
contagem: Counter[str], contagem: Counter[str],
inicio_acrescimo: date | None = None,
) -> None: ) -> None:
"""Substitui os lançamentos de uma conta pelos do arquivo novo. Lançamento """Substitui os lançamentos de uma conta pelos do arquivo novo. Lançamento
igual (mesma data, sequência, natureza e valor) é mantido com o mesmo id e igual (mesma data, sequência, natureza e valor) é mantido com o mesmo id e
o vínculo que tinha; vínculo com algum lançamento que saiu do razão é o vínculo que tinha; vínculo com algum lançamento que saiu do razão é
desfeito inteiro (o manual fica registrado no histórico). O motor roda de desfeito inteiro (o manual fica registrado no histórico). O motor roda de
novo só sobre o que ficou pendente, sem mexer nos vínculos mantidos nem nos novo só sobre o que ficou pendente, sem mexer nos vínculos mantidos nem nos
lançamentos que o contador tirou de um vínculo automático (`bloqueados`).""" lançamentos que o contador tirou de um vínculo automático (`bloqueados`).
`inicio_acrescimo` (arquivo que começa depois do início da conciliação):
os lançamentos antes dessa data (e o saldo anterior original) ficam como
estão; só o que vem a partir dela é comparado com o arquivo, e o saldo
anterior do arquivo não vira lançamento (já foi conferido contra o saldo
da conciliação em `_conciliacao_aplica_atualizacao`)."""
linhas_novas = _conciliacao_linhas_razao(conta, extraida, razao, conciliacao.data_base) linhas_novas = _conciliacao_linhas_razao(conta, extraida, razao, conciliacao.data_base)
prefixo: list[ConciliacaoFornecedorLancamento] = []
if inicio_acrescimo:
prefixo = [l for l in antigos if l.eh_saldo_anterior or l.data < inicio_acrescimo]
linhas_novas = [l for l in linhas_novas if not l.eh_saldo_anterior]
base = max((l.ordem for l in prefixo), default=-1) + 1
for pos, linha in enumerate(linhas_novas):
linha.ordem = base + pos
ids_prefixo = {l.id for l in prefixo}
comparacao = conciliacao_atualizacao.compara( comparacao = conciliacao_atualizacao.compara(
[ [
conciliacao_atualizacao.LancamentoComparavel(l.id, l.eh_saldo_anterior, l.data, l.sequencia, l.natureza, l.valor) conciliacao_atualizacao.LancamentoComparavel(l.id, l.eh_saldo_anterior, l.data, l.sequencia, l.natureza, l.valor)
for l in antigos for l in antigos
if l.id not in ids_prefixo
], ],
[ [
conciliacao_atualizacao.LancamentoComparavel(pos, l.eh_saldo_anterior, l.data, l.sequencia, l.natureza, l.valor) conciliacao_atualizacao.LancamentoComparavel(pos, l.eh_saldo_anterior, l.data, l.sequencia, l.natureza, l.valor)
@ -5802,7 +5820,13 @@ def _conciliacao_atualiza_conta(
if removidos: if removidos:
ConciliacaoFornecedorLancamento.objects.filter(id__in=removidos).delete() ConciliacaoFornecedorLancamento.objects.filter(id__in=removidos).delete()
final: list[ConciliacaoFornecedorLancamento] = [] # Acréscimo: o que fica antes do arquivo entra como está (o vínculo de um
# deles só cai se envolvia lançamento que saiu; no banco o SET_NULL já
# soltou, aqui é a cópia em memória que o motor usa).
for lanc in prefixo:
if lanc.vinculo_id in afetados:
lanc.vinculo = None
final: list[ConciliacaoFornecedorLancamento] = list(prefixo)
mantidos: list[ConciliacaoFornecedorLancamento] = [] mantidos: list[ConciliacaoFornecedorLancamento] = []
criar: list[ConciliacaoFornecedorLancamento] = [] criar: list[ConciliacaoFornecedorLancamento] = []
for pos, nova in enumerate(linhas_novas): for pos, nova in enumerate(linhas_novas):
@ -5849,7 +5873,9 @@ def _conciliacao_atualiza_conta(
conta.ordem = extraida.ordem conta.ordem = extraida.ordem
conta.classificacao = extraida.classificacao conta.classificacao = extraida.classificacao
conta.fornecedor = extraida.fornecedor[:255] conta.fornecedor = extraida.fornecedor[:255]
conta.saldo_inicial = extraida.saldo_inicial if not inicio_acrescimo:
# no acréscimo, o saldo inicial continua o do começo da conciliação
conta.saldo_inicial = extraida.saldo_inicial
conta.saldo_final = extraida.saldo_final_calculado conta.saldo_final = extraida.saldo_final_calculado
conta.saldo_final_arquivo = extraida.saldo_final_arquivo conta.saldo_final_arquivo = extraida.saldo_final_arquivo
campos = ["ordem", "classificacao", "fornecedor", "saldo_inicial", "saldo_final", "saldo_final_arquivo"] campos = ["ordem", "classificacao", "fornecedor", "saldo_inicial", "saldo_final", "saldo_final_arquivo"]
@ -5885,6 +5911,81 @@ def _conciliacao_aplica_periodo_fim(
razao.periodo_fim = periodo_fim razao.periodo_fim = periodo_fim
def _conciliacao_inicio_acrescimo(
conciliacao: ConciliacaoFornecedor, razao: conciliacao_parser.RazaoExtraido
) -> date | None:
"""Arquivo que começa depois do início da conciliação é um **acréscimo**
(pedido do usuário: anexar só o mês novo sem perder o trabalho): devolve a
data de início dele. Começando no início da conciliação ou antes, é a
substituição de sempre (None). Recusa o arquivo que deixa um buraco
depois do fim atual, porque os lançamentos desse intervalo ficariam de fora."""
inicio = razao.periodo_inicio
if not (inicio and conciliacao.periodo_inicio and inicio > conciliacao.periodo_inicio):
return None
fim = conciliacao.periodo_fim
if fim and inicio > fim + timedelta(days=1):
fmt = "%d/%m/%Y"
raise ValidationError(
{
"arquivo": (
f"O arquivo começa em {inicio:{fmt}}, mas a conciliação vai até {fim:{fmt}}: "
f"falta o razão de {fim + timedelta(days=1):{fmt}} a {inicio - timedelta(days=1):{fmt}}. "
f"Exporte o razão a partir de {fim + timedelta(days=1):{fmt}}."
)
}
)
return inicio
def _conciliacao_saldo_texto(valor: Decimal) -> str:
"""Saldo como na tela: "R$ 1.234,56 C" (credor) / "D" (devedor)."""
texto = f"{abs(valor):,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
return f"R$ {texto} {'D' if valor < 0 else 'C'}" if valor else "R$ 0,00"
def _conciliacao_confere_saldos_acrescimo(
pares: list[tuple[conciliacao_parser.ContaExtraida, ConciliacaoFornecedorConta | None]],
lancs_por_conta: dict[int, list[ConciliacaoFornecedorLancamento]],
inicio: date,
) -> None:
"""No acréscimo, o saldo inicial de cada conta do arquivo tem de bater com
o saldo da conciliação na véspera do início dele (pedido do usuário):
saldo inicial da conciliação + lançamentos antes dessa data. No caso
comum (arquivo começa no dia seguinte ao fim atual) é o saldo final da
conta. Diferença em qualquer conta recusa a atualização inteira, antes de
gravar qualquer coisa, listando as contas."""
divergentes: list[str] = []
for extraida, conta in pares:
if conta is None:
continue
esperado = conta.saldo_inicial + sum(
(
lanc.valor if lanc.natureza == "C" else -lanc.valor
for lanc in lancs_por_conta.get(conta.id, [])
if not lanc.eh_saldo_anterior and lanc.data < inicio
),
Decimal("0"),
)
if extraida.saldo_inicial != esperado:
divergentes.append(
f"{conta.conta_numero} - {conta.fornecedor}: arquivo {_conciliacao_saldo_texto(extraida.saldo_inicial)}, "
f"conciliação {_conciliacao_saldo_texto(esperado)}"
)
if divergentes:
vespera = inicio - timedelta(days=1)
lista = "\n".join(divergentes[:15])
resto = f"\n... e mais {len(divergentes) - 15} conta(s)." if len(divergentes) > 15 else ""
raise ValidationError(
{
"arquivo": (
f"O saldo inicial do arquivo não bate com o saldo da conciliação em {vespera:%d/%m/%Y} "
f"em {len(divergentes)} conta(s). Nada foi gravado. Confira se o razão é da mesma empresa "
f"e se houve lançamento alterado no Questor nesse período:\n{lista}{resto}"
)
}
)
def _conciliacao_aplica_atualizacao( def _conciliacao_aplica_atualizacao(
conciliacao: ConciliacaoFornecedor, razao: conciliacao_parser.RazaoExtraido, usuario: Usuario conciliacao: ConciliacaoFornecedor, razao: conciliacao_parser.RazaoExtraido, usuario: Usuario
) -> dict[str, int]: ) -> dict[str, int]:
@ -5919,13 +6020,28 @@ def _conciliacao_aplica_atualizacao(
): ):
bloqueados_por_conta.setdefault(registro.conta_id, set()).update(l["id"] for l in registro.lancamentos) bloqueados_por_conta.setdefault(registro.conta_id, set()).update(l["id"] for l in registro.lancamentos)
contagem: Counter[str] = Counter() inicio_acrescimo = _conciliacao_inicio_acrescimo(conciliacao, razao)
# Pareamento conta do arquivo -> conta da conciliação (None = nova).
pares: list[tuple[conciliacao_parser.ContaExtraida, ConciliacaoFornecedorConta | None]] = []
usadas: set[int] = set() usadas: set[int] = set()
for extraida in razao.contas: for extraida in razao.contas:
conta = por_chave.get((extraida.conta_numero, extraida.classificacao)) conta = por_chave.get((extraida.conta_numero, extraida.classificacao))
if conta is None or conta.id in usadas: if conta is None or conta.id in usadas:
conta = next((c for c in por_numero.get(extraida.conta_numero, []) if c.id not in usadas), None) conta = next((c for c in por_numero.get(extraida.conta_numero, []) if c.id not in usadas), None)
if conta is None: if conta is not None:
usadas.add(conta.id)
pares.append((extraida, conta))
if inicio_acrescimo:
_conciliacao_confere_saldos_acrescimo(pares, lancs_por_conta, inicio_acrescimo)
contagem: Counter[str] = Counter()
if inicio_acrescimo:
contagem["acrescimo"] = 1
for extraida, conta in pares:
nova = conta is None
if nova:
conta = ConciliacaoFornecedorConta.objects.create( conta = ConciliacaoFornecedorConta.objects.create(
conciliacao=conciliacao, conciliacao=conciliacao,
ordem=extraida.ordem, ordem=extraida.ordem,
@ -5937,7 +6053,6 @@ def _conciliacao_aplica_atualizacao(
saldo_final_arquivo=extraida.saldo_final_arquivo, saldo_final_arquivo=extraida.saldo_final_arquivo,
) )
contagem["contas_novas"] += 1 contagem["contas_novas"] += 1
usadas.add(conta.id)
_conciliacao_atualiza_conta( _conciliacao_atualiza_conta(
conciliacao, conciliacao,
conta, conta,
@ -5948,14 +6063,23 @@ def _conciliacao_aplica_atualizacao(
bloqueados_por_conta.get(conta.id, set()), bloqueados_por_conta.get(conta.id, set()),
usuario, usuario,
contagem, contagem,
# conta que aparece pela primeira vez entra inteira, com o saldo
# anterior do arquivo como lançamento (não há o que preservar)
None if nova else inicio_acrescimo,
) )
sairam = [c.id for c in existentes if c.id not in usadas] sairam = [c.id for c in existentes if c.id not in usadas]
if sairam: if inicio_acrescimo:
ConciliacaoFornecedorConta.objects.filter(id__in=sairam).delete() # No acréscimo, conta fora do arquivo é conta sem movimento no período
contagem["contas_removidas"] = len(sairam) # novo: fica como está (não é excluída, como na substituição).
contagem["contas_sem_movimento"] = len(sairam)
else:
if sairam:
ConciliacaoFornecedorConta.objects.filter(id__in=sairam).delete()
contagem["contas_removidas"] = len(sairam)
conciliacao.data_base = timezone.localdate() conciliacao.data_base = timezone.localdate()
conciliacao.periodo_inicio = razao.periodo_inicio if not inicio_acrescimo:
conciliacao.periodo_inicio = razao.periodo_inicio
conciliacao.periodo_fim = razao.periodo_fim conciliacao.periodo_fim = razao.periodo_fim
conciliacao.save(update_fields=["data_base", "periodo_inicio", "periodo_fim"]) conciliacao.save(update_fields=["data_base", "periodo_inicio", "periodo_fim"])
@ -6271,9 +6395,9 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["get"]) @action(detail=True, methods=["get"])
def relatorio(self, request: Request, pk: str | None = None) -> HttpResponse: def relatorio(self, request: Request, pk: str | None = None) -> HttpResponse:
"""Relatório de análise para o contador (decisões tomadas e situações """Relatório de análise para o contador (decisões tomadas e situações
de risco): HTML autocontido com a marca do escritório, aberto em nova de risco): trecho de HTML no visual do Portal, inserido pela tela na
aba pela tela (GET, mesmo motivo de `ContabilApuracaoViewSet.dashboard`). aba "Relatório" do detalhe (uso interno, nada vai ao cliente). Só
Só leitura, pode ser gerado com a conciliação em andamento.""" leitura, pode ser gerado com a conciliação em andamento."""
conciliacao = self.get_object() conciliacao = self.get_object()
contexto = { contexto = {
"conciliacao": conciliacao, "conciliacao": conciliacao,
@ -6281,24 +6405,25 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
"meses_vencido": conciliacao_alertas.MESES_VENCIDO, "meses_vencido": conciliacao_alertas.MESES_VENCIDO,
"janela_duplicidade": conciliacao_relatorio.JANELA_DIAS_DUPLICIDADE, "janela_duplicidade": conciliacao_relatorio.JANELA_DIAS_DUPLICIDADE,
"gerado_em": timezone.now(), "gerado_em": timezone.now(),
"logo_data_uri": _logo_relatorio_data_uri(),
} }
html = render_to_string("conciliacao-fornecedores-relatorio.html", contexto) html = render_to_string("conciliacao-fornecedores-relatorio.html", contexto)
return HttpResponse(html, content_type="text/html; charset=utf-8") return HttpResponse(html, content_type="text/html; charset=utf-8")
def _conciliacao_tipo_vinculo_manual( def _conciliacao_tipo_vinculo_manual(
conciliacao: ConciliacaoFornecedor, lancs: list[ConciliacaoFornecedorLancamento] conciliacao: ConciliacaoFornecedor, lancs: list[ConciliacaoFornecedorLancamento], fechar: bool = False
) -> tuple[str, Decimal]: ) -> tuple[str, Decimal]:
"""Tipo e diferença de um vínculo manual. Créditos maiores que os débitos """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 além da tolerância: pagamento parcial (parcelamento), o saldo continua em
da tolerância (ou pago a maior), fecha como vínculo com diferença aberto, salvo `fechar` (o contador escolheu "Desconto"). 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.""" (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")) 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) 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")) 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 limite = Decimal(tolerancia.limite_centavos(int(total_creditos * 100))) / 100
tipo = conciliacao_motor.TIPO_PARCIAL if diferenca > limite else conciliacao_motor.TIPO_MANUAL parcial = diferenca > limite and not fechar
tipo = conciliacao_motor.TIPO_PARCIAL if parcial else conciliacao_motor.TIPO_MANUAL
return tipo, diferenca return tipo, diferenca
@ -6357,7 +6482,7 @@ class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet):
raise ValidationError({"lancamentos": "Há lançamentos que já estão vinculados. Desfaça o vínculo antes."}) 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"}: 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."}) raise ValidationError({"lancamentos": "Selecione pelo menos um débito e um crédito."})
tipo, diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, lancs) tipo, diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, lancs, entrada.validated_data["fechar"])
vinculo = ConciliacaoFornecedorVinculo.objects.create( vinculo = ConciliacaoFornecedorVinculo.objects.create(
conta=conta, tipo=tipo, diferenca=diferenca, criado_por=request.user conta=conta, tipo=tipo, diferenca=diferenca, criado_por=request.user
) )
@ -6455,6 +6580,14 @@ class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet):
request.user, request.user,
) )
vinculo.delete() vinculo.delete()
# Desfazer um vínculo muda a conta que já foi conferida: a
# validação sai e ela volta a "a validar" (pedido do usuário). A
# observação fica, como na atualização do razão.
if conta.validada:
conta.validada = False
conta.validada_por = None
conta.validada_em = None
conta.save(update_fields=["validada", "validada_por", "validada_em"])
return _conciliacao_resposta_conta(conta) return _conciliacao_resposta_conta(conta)
@action(detail=True, methods=["post"]) @action(detail=True, methods=["post"])
@ -6478,7 +6611,9 @@ class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet):
raise ValidationError({"lancamentos": "Há lançamentos que já estão vinculados. Desfaça o vínculo antes."}) 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) ConciliacaoFornecedorLancamento.objects.filter(id__in=ids).update(vinculo=vinculo)
todos = list(ConciliacaoFornecedorLancamento.objects.filter(vinculo=vinculo)) todos = list(ConciliacaoFornecedorLancamento.objects.filter(vinculo=vinculo))
vinculo.tipo, vinculo.diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, todos) vinculo.tipo, vinculo.diferenca = _conciliacao_tipo_vinculo_manual(
conta.conciliacao, todos, entrada.validated_data["fechar"]
)
vinculo.criado_por = request.user vinculo.criado_por = request.user
vinculo.save(update_fields=["tipo", "diferenca", "criado_por"]) vinculo.save(update_fields=["tipo", "diferenca", "criado_por"])
_conciliacao_registra_historico( _conciliacao_registra_historico(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -405,13 +405,11 @@
<thead> <thead>
<tr> <tr>
<th>Empresa</th> <th>Empresa</th>
<th>Data-base</th>
<th>Período</th> <th>Período</th>
<th class="conc-num">Fornecedores</th> <th class="conc-num">Fornecedores</th>
<th class="conc-num">Conciliados</th> <th class="conc-num">Críticos</th>
<th class="conc-num">Divergentes</th>
<th class="conc-num">Em aberto</th> <th class="conc-num">Em aberto</th>
<th>Criado por</th> <th>Última atualização</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -443,10 +441,6 @@
<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> <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 Exportar XLSX
</a> </a>
<a class="btn-solid" id="conc-relatorio-btn" href="#" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>
Gerar relatório
</a>
</div> </div>
</div> </div>
@ -458,18 +452,18 @@
<div class="pa-tabs conc-abas" id="conc-abas"> <div class="pa-tabs conc-abas" id="conc-abas">
<button type="button" class="pa-tab is-active" data-conc-aba="conciliacao">Conciliação</button> <button type="button" class="pa-tab is-active" data-conc-aba="conciliacao">Conciliação</button>
<button type="button" class="pa-tab" data-conc-aba="vencidos">Em aberto há mais de 3 meses <span class="conc-chip__count" id="conc-aba-vencidos-qtd">0</span></button> <button type="button" class="pa-tab" data-conc-aba="vencidos">Em aberto há mais de 3 meses <span class="conc-chip__count" id="conc-aba-vencidos-qtd">0</span></button>
<button type="button" class="pa-tab" data-conc-aba="relatorio">Relatório</button>
</div> </div>
<div class="pa-tab-panel" data-conc-painel="conciliacao"> <div class="pa-tab-panel" data-conc-painel="conciliacao">
<div class="conc-toolbar conc-toolbar--fixa"> <div class="conc-toolbar conc-toolbar--fixa">
<div class="conc-chip-row" id="conc-filtro-status"> <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 is-active" data-conc-grupo="pendentes">Pendentes</button>
<button type="button" class="conc-chip" data-conc-status="divergente">Divergentes</button> <button type="button" class="conc-chip" data-conc-grupo="parcial">Parcial</button>
<button type="button" class="conc-chip" data-conc-status="atencao">Atenção</button> <button type="button" class="conc-chip" data-conc-grupo="saldo_aberto">Saldo em aberto</button>
<button type="button" class="conc-chip" data-conc-status="conciliada">Conciliados</button> <button type="button" class="conc-chip" data-conc-grupo="conciliados">Conciliados</button>
<button type="button" class="conc-chip" data-conc-status="validada">Validados</button> <button type="button" class="conc-chip" data-conc-grupo="validados">Validados</button>
</div> </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"> <div class="pa-search">
<input type="search" id="conc-contas-busca" name="busca_fornecedor" placeholder="Buscar fornecedor ou conta…" autocomplete="off" /> <input type="search" id="conc-contas-busca" name="busca_fornecedor" 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> <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>
@ -484,11 +478,11 @@
<th class="conc-expand-col"></th> <th class="conc-expand-col"></th>
<th data-sort="conta_numero">Conta <span class="conc-sort-icon">↕</span></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 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 final <span class="conc-sort-icon">↕</span></th>
<th class="conc-num" data-sort="saldo_final">Saldo residual <span class="conc-sort-icon">↕</span></th> <!-- Filtro por ajuste no próprio cabeçalho (menu `pidConcAbreMenuAjuste`) -->
<th class="conc-num" data-sort="total_vencido">Em aberto &gt; 3 meses <span class="conc-sort-icon">↕</span></th> <th class="conc-ajuste-col"><button type="button" class="conc-ajuste-th" id="conc-ajuste-th" aria-haspopup="menu">Ajuste <span class="conc-ajuste-th__ativo" id="conc-ajuste-th-ativo"></span><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg></button></th>
<th data-sort="status">Situação <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> <th class="conc-validado-col" data-sort="validada_em">Validado <span class="conc-sort-icon">↕</span></th>
</tr> </tr>
</thead> </thead>
<tbody id="conc-contas-body"></tbody> <tbody id="conc-contas-body"></tbody>
@ -529,6 +523,21 @@
</div> </div>
<p class="conc-empty" id="conc-venc-vazia" hidden></p> <p class="conc-empty" id="conc-venc-vazia" hidden></p>
</div> </div>
<!-- Relatório de análise (trecho de HTML do servidor), gerado de
novo a cada vez que a aba é aberta -->
<div class="pa-tab-panel" data-conc-painel="relatorio" hidden>
<div class="conc-relatorio-barra">
<span class="conc-relatorio-barra__texto" id="conc-relatorio-gerado"></span>
<label class="conc-toggle"><input type="checkbox" id="conc-relatorio-anexo" /> Incluir anexo na impressão</label>
<button type="button" class="btn-ghost conc-btn-sm" id="conc-relatorio-recarregar">Atualizar relatório</button>
<button type="button" class="btn-solid conc-btn-sm" id="conc-relatorio-imprimir" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
Imprimir
</button>
</div>
<div id="conc-relatorio-conteudo"></div>
</div>
</section> </section>
</div> </div>
</main> </main>
@ -607,11 +616,11 @@
<h2 class="modal-card__title">Atualizar razão</h2> <h2 class="modal-card__title">Atualizar razão</h2>
<p class="modal-card__subtitle" id="conc-atualizar-empresa"></p> <p class="modal-card__subtitle" id="conc-atualizar-empresa"></p>
<div class="conc-atualizar-info"> <div class="conc-atualizar-info">
<p>O arquivo novo <strong>substitui</strong> o razão desta conciliação, e a análise é refeita sobre o período dele.</p> <p>O tratamento depende de onde o arquivo começa:</p>
<ul> <ul>
<li>O que ficou antes do início do arquivo novo passa a ser só o saldo anterior de cada fornecedor.</li> <li><strong>Só o período novo</strong> (começa depois do início da conciliação, ex.: só o mês seguinte): os lançamentos são <strong>acrescentados</strong> e tudo o que já existe fica como está. O saldo inicial de cada fornecedor no arquivo precisa bater com o saldo da conciliação; se algum não bater, nada é gravado.</li>
<li>Vínculos, validações e observações são mantidos quando os lançamentos envolvidos continuam no arquivo novo; vínculo com lançamento que saiu do razão é desfeito.</li> <li><strong>Razão desde o início</strong> (começa no início da conciliação ou antes): o arquivo <strong>substitui</strong> o razão. Vínculos e validações ficam onde os lançamentos continuam no arquivo; o que ficar antes do início dele vira só o saldo anterior.</li>
<li>Conta que recebeu ou perdeu lançamentos volta a "a validar" (a observação fica).</li> <li>Nos dois casos, conta que recebeu ou perdeu lançamentos volta a "a validar" (a observação fica).</li>
</ul> </ul>
<p class="conc-atualizar-info__atual" id="conc-atualizar-periodo"></p> <p class="conc-atualizar-info__atual" id="conc-atualizar-periodo"></p>
</div> </div>