Validações de regras de conciliação fornecedores
This commit is contained in:
parent
7f6db34942
commit
624f69dafd
@ -183,3 +183,52 @@ Pedido do usuário (BRASPRESS, título de R$ 42,74 com a sugestão "Pode ter sid
|
||||
### Rodada 36 — "Em aberto há mais de 3 meses" deixa de ser situação no razão (2026-09-29)
|
||||
|
||||
Pedido do usuário: no razão da conta expandida, "Em aberto há mais de 3 meses." não é uma situação, já que existe a aba própria para esses títulos. A `descricao` da categoria `vencido` passou a ser "Em aberto." (mesmo texto de `em_aberto`), e `.conc-situacao--vencido` perdeu a cor dourada (fica com a cor neutra de `.conc-situacao`). A categoria `vencido` continua no payload, porque alimenta a aba "Em aberto há mais de 3 meses" (`titulosVencidos()`), o `total_vencido` e o card do topo. O XLSX (aba Pendências) passa a mostrar "Em aberto." para esses títulos; a coluna "Em aberto há mais de 3 meses" do Resumo não mudou.
|
||||
|
||||
### Rodada 37 — Títulos da sugestão um abaixo do outro no balão (2026-09-29)
|
||||
|
||||
Pedido do usuário: na sugestão "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..." com vários títulos, o balão mostrava tudo corrido e era difícil comparar os valores. Com mais de um título, `alertas.py` passou a montar o texto em linhas (`\n`): o cabeçalho ("...; os mais antigos em aberto:"), um título por linha ("11/05/2026 (R$ 32,54)") e, na última, "Soma: R$ X; saldo residual de R$ Y em aberto." (ou "o pagamento excede em R$ Y"). Com um título só, o texto continua numa linha ("o mais antigo em aberto: ..."). O balão (`.conc-tooltip`) respeita as quebras (`white-space: pre-line`) e usa algarismos tabulares, para os valores ficarem alinhados. Na célula da tabela e no XLSX, o texto é juntado numa linha só (`pidConcTextoEmLinha()` no JS, `texto_em_linha()` em `alertas.py`, mesma regra: "; " entre itens). Testado com os valores do print (Python e a função JS isolada); sem renderização neste ambiente.
|
||||
|
||||
### Rodada 38 — Linha temporária do saldo a pagar no razão (2026-09-29)
|
||||
|
||||
Pergunta do usuário sobre o saldo residual (os títulos não são abatidos enquanto não há vínculo; num vínculo parcial, a diferença continua em aberto, mas só aparecia no bloco "Pagamentos parciais vinculados", abaixo do razão), seguida do pedido: mostrar uma linha temporária com a diferença quando restar saldo a pagar. Só frontend: no razão da conta expandida, depois do último lançamento de cada vínculo `parcial`, entra a linha "Saldo a pagar do vínculo #N" (natureza "A pagar", valor e em aberto = saldo, contrapartida "Pagos R$ X de R$ Y em títulos", situação e sugestão do saldo, as mesmas de `vinculo.situacao`). Sem checkbox, data, seq. nem saldo do Questor, porque não é lançamento do arquivo; fundo dourado discreto com borda tracejada e itálico para não ser confundida com um. Tem `data-vinculo-id`, então acende junto com o vínculo no hover e com a sugestão que cita o saldo (id negativo). Não é escondida por "Ocultar conciliados". Desaparece sozinha quando o vínculo deixa de ser parcial (fecha acrescentando pagamento) ou é desfeito. O bloco "Pagamentos parciais vinculados" continua. Decisões minhas, não confirmadas: posição (depois do último lançamento do vínculo, não na data do título mais antigo) e a linha não ser selecionável (acrescentar pagamento continua pelo "Adicionar selecionados" do bloco). `node --check` ok; sem renderização neste ambiente.
|
||||
|
||||
Na mesma rodada, depois do print do usuário (seleção pelo duplo clique na sugestão ficou com diferença de -R$ 46,74: a sugestão citava como "título" o saldo do vínculo #28, que não tem checkbox): o saldo a pagar é **só informativo**, não deve entrar nos cálculos de outras conciliações. Em `analisa_conta()`, o saldo de vínculo parcial saiu de `_analisa_pendentes()`: não entra mais na distribuição (não é citado na sugestão de nenhum pagamento, não abate excedente) e não tem sugestão nem `relacionados`; continua com situação própria ("Em aberto.", categoria `em_aberto`/`vencido` pela data do título mais antigo do vínculo) e nos totais `total_em_aberto`/`total_vencido`, e segue na aba de títulos antigos. Efeito colateral: um pagamento que só cabia usando esse saldo pode passar a contar como excedente (divergência), porque o saldo deixou de absorvê-lo. Testado com dados sintéticos (vínculo parcial de R$ 46,74 + título de R$ 120,18 + pagamento de R$ 616,82: sugestão cita só o título real).
|
||||
|
||||
### Rodada 39 — Sem sugestão de pagamento anterior ao título (2026-09-29)
|
||||
|
||||
Pedido do usuário (BRASPRESS: pagamento de 26/06 de R$ 127,61 sugerido como "possível pagamento" do título de 08/07 de R$ 126,38): o pagamento dificilmente é feito antes do crédito, então essa sugestão não deve existir. Em `_analisa_pendentes()` (`alertas.py`): (1) o par de **valor próximo** exige pagamento na data do título ou depois; (2) a **parcela** só é procurada em títulos até a data do pagamento; (3) na **ordem de antiguidade**, os títulos até a data do pagamento vêm primeiro, e os posteriores só entram depois, para a conta do excedente (adiantamento), sem aparecer no texto da sugestão nem em `relacionados`; a "Soma" e o "excede em" do texto passaram a considerar só os títulos citados. O **par de mesmo valor não mudou**: continua aceitando pagamento antes do título (com prioridade menor), como `_pareia_exato` do motor, porque adiantamento a fornecedor foi confirmado como pagamento normal na rodada 1. Conferido na conciliação 20 (só leitura, versão anterior do `alertas.py` contra a nova): nenhuma conta mudou de situação nem de excedente (233 conciliadas / 26 atenção / 1 divergente); o pagamento do print passou a sugerir o título de 09/06/2026 (R$ 120,18, R$ 7,43 a mais) e o título de 08/07 ficou sem sugestão.
|
||||
|
||||
### Rodada 40 — Saldo a pagar abaixo do último crédito do vínculo (2026-09-29)
|
||||
|
||||
Pedido do usuário (ROZINI, vínculo #10: NF de 28/05 de R$ 8.654,22 + pagamento de 26/06 de R$ 2.163,57): a linha "Saldo a pagar" (rodada 38 deste arquivo) ficava depois do último lançamento do vínculo, o pagamento, e deve ficar abaixo do último crédito vinculado. `linhasRazaoHtml()` passou a posicioná-la depois do último crédito do vínculo no razão; se o vínculo não tiver crédito no período exibido, continua depois do último lançamento dele. Só frontend; `node --check` ok, sem renderização neste ambiente.
|
||||
|
||||
### Rodada 41 — Saldo a pagar disponível para vínculo e para sugestão concreta (2026-09-29)
|
||||
|
||||
Pedido do usuário (ROZINI, vínculo #10: NF de R$ 8.654,22 com 1 pagamento de R$ 2.163,57, restando R$ 6.490,65): antes de vincular o primeiro pagamento, os dois pagamentos tinham sugestão de parcela; depois, o de 14/07 (seq. 40065, R$ 2.163,55) ficou sem sugestão e não havia como aplicá-la. O que falta pagar deve continuar disponível para vínculo, inclusive nas sugestões. Isso revê em parte a rodada 38 deste arquivo, em que o saldo tinha saído de toda a distribuição (o problema ali era ele aparecer na sugestão genérica "os mais antigos em aberto" sem poder ser selecionado).
|
||||
|
||||
- `alertas.py`: o saldo de vínculo parcial voltou para `_analisa_pendentes()` como título (id negativo), mas **só por base concreta**: par de mesmo valor, valor próximo e parcela; fica fora da ordem de antiguidade (`_eh_saldo_parcial()`). Texto próprio (`_ref_titulo()`): "Possível parcela do saldo a pagar do título de 28/05/2026 (R$ 6.490,65 em aberto), em 3x de R$ 2.163,55. Vincule para confirmar."; do lado do saldo, "Possível parcelamento: 1 pagamento(s) de parcela deste saldo, somando R$ 2.163,55; R$ 4.327,10 ainda em aberto." Conferido na ROZINI da conciliação 21 (só leitura).
|
||||
- Tela: a linha "Saldo a pagar do vínculo #N" ganhou checkbox (id negativo na seleção, crédito pelo valor do saldo na soma da barra). Com ela marcada, "Vincular selecionados" vira "Adicionar ao vínculo #N" e acrescenta os demais selecionados ao vínculo (`adicionar/`, mesma confirmação do "Adicionar selecionados" do bloco, agora `adicionarAoVinculo()` compartilhada). Mais de um saldo marcado desabilita o botão ("Marque só um saldo a pagar"). "Marcar todos" não marca saldos, mas desmarcar limpa tudo. O duplo clique na sugestão marca o pagamento e o saldo citado, então no caso do print basta um duplo clique e "Adicionar ao vínculo #10". `node --check` ok, sem renderização neste ambiente.
|
||||
|
||||
Ao testar, a sugestão não apareceu na tela do usuário: havia dois `runserver` na mesma porta (iniciados às 16:06:47 e 16:17:37), ambos anteriores à última gravação do `alertas.py` (16:17:44). Conferido pelo próprio endpoint de detalhe (dentro de transação desfeita) que o código atual gera a sugestão; o usuário reiniciou e confirmou o vínculo.
|
||||
|
||||
### Rodada 42 — Conta conciliada por ajuste manual não é validada automaticamente (2026-09-29)
|
||||
|
||||
Pedido do usuário (ROZINI: depois de acrescentar o pagamento ao vínculo parcial, a conta ficou "Conciliada" e foi marcada como validada sozinha): quando a conta passa a conciliada por vinculação manual, a validação deve ser manual. `_conciliacao_sincroniza_validacao_automatica()` recebe `ajustada_manualmente`: conta com algum registro em `ConciliacaoFornecedorVinculoHistorico` (vinculou, acrescentou ou desfez, inclusive desfazer vínculo automático) não recebe validação automática e perde a automática que tinha; a validação feita por uma pessoa continua intocada. Vale depois de vincular/acrescentar/desfazer (`conta.historico_vinculos.exists()`), na criação e ao abrir a conciliação (`_conciliacao_contas_ajustadas()`, uma consulta para a conciliação inteira). Conferido na conciliação 21 (só leitura): 1 conta com ajuste manual (a ROZINI, já desmarcada pelo usuário), nenhuma validação mudaria hoje. Decisão minha, não confirmada: "desfazer" também conta como ajuste manual.
|
||||
|
||||
### Rodada 43 — Combinação e quitação dos mais antigos viram sugestão (2026-09-29)
|
||||
|
||||
Pedido do usuário (EBAZAR, vínculo #15 "Combinação de valores · automático"): combinação de valores não deve vincular automaticamente, apenas trazer na sugestão. Ao simular o motor sem a combinação na empresa 2017, parte das 99 combinações continuava automática por outras etapas (33 viravam "quitação dos mais antigos", 28 caíam na compensação de segmento fechado). Decisões do usuário, perguntadas antes de seguir: (1) a **quitação dos títulos mais antigos** (etapa 4, rodada 32) também vira sugestão; (2) a **compensação do segmento fechado** continua automática; (3) os vínculos já gravados (conciliação 21) **ficam** como estão, e a regra vale para novas importações (mesmo tratamento da rodada 30).
|
||||
|
||||
- `motor.py`: `concilia()` roda só o par exato e a compensação do segmento fechado. `_combina`, `_quita_mais_antigos` e os tipos `combinacao`/`sequencia` ficam pelos vínculos antigos.
|
||||
- `alertas.py`: `_combinacoes_exatas()` (reaproveita `_busca_combinacao` do motor, mesmos limites: 2 a 6 itens, 120 dias, 12 candidatos mais próximos), numa passada logo depois dos pares de mesmo valor, com pagamento nunca antes do título (rodada 39). O alvo recebe "Soma exata de N pagamentos:"/"Soma exata de N títulos:" com um item por linha; cada membro, "Com outro(s) N-1 pagamento(s)/título(s), soma exatamente o ... Vincule para confirmar.", e o duplo clique na sugestão marca o grupo inteiro (`relacionados`). A quitação dos mais antigos saiu da ordem de antiguidade: quando 2 ou mais títulos mais antigos, inteiros e até a data do pagamento, fecham exatamente o pagamento, a sugestão é "Soma exata dos N títulos mais antigos em aberto:", e cada título ganha "Com outro(s) N-1 título(s) mais antigo(s), soma exatamente o pagamento de ...".
|
||||
- Tela: o critério dos tipos `combinacao`/`sequencia` no modal "Vínculo #N" avisa que só valeu em importações até 29/09/2026.
|
||||
|
||||
Simulação de uma importação nova da empresa 2017 (só leitura, versão anterior do motor e do `alertas.py` contra a nova): vínculos automáticos de 532 exatos + 99 combinações + 8 quitações + 15 compensações para 532 exatos + 78 compensações; situações de 231 conciliadas / 28 atenção / 1 divergente para 223 / 36 / 1 (8 contas passam a ter pagamento a vincular); excedente total igual (R$ 20.707,58); 29 sugestões "Soma exata", 76 "Com outro(s)"; mesmo tempo de análise. O vínculo #15 do print (pagamento de 21/02 de R$ 73,44) usava dois títulos de 23/02, posteriores ao pagamento, e por isso **não** volta como sugestão de combinação.
|
||||
|
||||
### Rodada 44 — Parcela: até 12x e só diferença de arredondamento (2026-09-29)
|
||||
|
||||
Pedido do usuário (título de 12/05/2026 de R$ 6.086,60 com o pagamento de R$ 269,41 sugerido como "parcela em 23x de R$ 264,64, com R$ 4,77 a mais (juros/desconto?)"): parcelas demais, e estimar juros para achar uma parcela não faz sentido. `_parcela()` (`alertas.py`): `MAX_PARCELAS` de 24 para **12**, e a diferença aceita passou de até 2% da parcela (`TOLERANCIA_PERCENTUAL_PADRAO`, rodada 20 deste arquivo) para até **R$ 0,10** (`DIFERENCA_MAX_PARCELA`, só arredondamento); o texto diz "(arredondamento)" em vez de "(juros/desconto?)". O caso da ROZINI da rodada 20 (4x, R$ 0,01 a mais) continua reconhecido. Os valores 12x e R$ 0,10 são escolha minha, não confirmada. Conferido na conciliação 21 (só leitura): sugestões de parcela de 121 para 53 (as 37 acima de 12x e 65 das 67 com diferença saíram); situações das contas iguais (235 / 24 / 1). No caso do print, o pagamento de R$ 1.217,32 continua como "parcela em 5x" (exata) e o de R$ 269,41 fica só com a sugestão fraca do mais antigo em aberto.
|
||||
|
||||
### Rodada 45 — Sem sugestão quando não há justificativa (2026-09-29)
|
||||
|
||||
Pedido do usuário (FLASH COMPANY: pagamentos com "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..."): quando não houver justificativa para a sugestão, ela não deve ser feita; as sugestões devem fazer sentido. A sugestão fraca pela ordem de antiguidade, do lado do pagamento (rodadas 24 e 28 deste arquivo; no lado do título já tinha saído na rodada 35), deixou de existir: sem par de mesmo valor, valor próximo, combinação exata, parcela ou quitação exata dos mais antigos, o pagamento fica só "Pagamento não vinculado", sem sugestão nem `relacionados`. A distribuição por antiguidade continua, só para calcular o excedente (divergência). Conferido na conciliação 21 (só leitura, contra a versão da rodada 44): nenhuma conta mudou de situação nem de excedente; sobram 79 sugestões (52 de parcela, 19 de parcelamento no título, 4 de valor próximo, 4 de combinação/quitação); a FLASH COMPANY fica sem nenhuma.
|
||||
|
||||
@ -31,12 +31,14 @@ Função pura `concilia(itens, tolerancia) -> list[VinculoSugerido]`, uma conta
|
||||
|
||||
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. **Quitação dos títulos mais antigos** (`_quita_mais_antigos`, `tipo="sequencia"`, desde 2026-09-25): cada pagamento pendente, em ordem de data, contra a sequência dos títulos mais antigos em aberto (data até a do pagamento); vira vínculo só se a soma fechar **exatamente**, com 2 títulos ou mais. Sem limite de itens nem de candidatos, porque há uma única sequência possível (não é subset-sum). Motivo: fretes acumulados que a combinação não achava por estarem fora dos 12 mais próximos (BRASPRESS: R$ 234,40 = 4 títulos; R$ 616,82 = 7; TEX COURIER: R$ 468,74 = 11). Roda em todo segmento, inclusive nos fechados, antes da compensação.
|
||||
5. **Compensação do segmento fechado**: o que sobrou num segmento que zerou vira um vínculo `saldo_zerado`.
|
||||
**Desde 2026-09-29 (rodada 43 de `CHANGELOG.md` nesta pasta), só o valor exato 1:1 e a compensação do segmento fechado vinculam sozinhos**: as etapas 3 e 4 viraram sugestão em `alertas.py` (pedido do usuário: "combinação de valores não deve vincular automaticamente, apenas trazer na sugestão"; a quitação dos mais antigos foi junto, por decisão do usuário). `TIPO_COMBINACAO`/`TIPO_SEQUENCIA`, `_combina` e `_quita_mais_antigos` continuam no código pelos vínculos gravados antes.
|
||||
|
||||
3. ~~Combinação exata~~ (sugestão desde 2026-09-29) (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. ~~Quitação dos títulos mais antigos~~ (sugestão desde 2026-09-29) (`_quita_mais_antigos`, `tipo="sequencia"`, desde 2026-09-25): cada pagamento pendente, em ordem de data, contra a sequência dos títulos mais antigos em aberto (data até a do pagamento); vira vínculo só se a soma fechar **exatamente**, com 2 títulos ou mais. Sem limite de itens nem de candidatos, porque há uma única sequência possível (não é subset-sum). Motivo: fretes acumulados que a combinação não achava por estarem fora dos 12 mais próximos (BRASPRESS: R$ 234,40 = 4 títulos; R$ 616,82 = 7; TEX COURIER: R$ 468,74 = 11). Roda em todo segmento, inclusive nos fechados, antes da compensação.
|
||||
5. **Compensação do segmento fechado**: o que sobrou num segmento que zerou vira um vínculo `saldo_zerado`. Continua automática (decisão do usuário, 2026-09-29) e passou a absorver o que a combinação e a quitação dos mais antigos vinculavam nesses segmentos.
|
||||
5. ~~Tolerância~~ **removida em 2026-09-25** (pedido do usuário: "no caso de juros, não vincular automaticamente, mas sugerir e apontar a possibilidade de juros"). Era 1:1 ou 2 itens com diferença até o limite, só no último segmento. Hoje esse par fica pendente e vira **sugestão** em `alertas.py` (ver "valor próximo" abaixo). `TIPO_TOLERANCIA` continua existindo pelos vínculos gravados antes (21 numa conciliação, na data da mudança); `concilia()` não recebe mais `tolerancia`.
|
||||
|
||||
Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensações, 21 com diferença.
|
||||
Resultado no arquivo real (empresa 2017), com o motor de hoje: 532 vínculos exatos e 78 compensações de segmento fechado (antes de 2026-09-29: 532 exatos, 99 combinações, 8 quitações dos mais antigos, 15 compensações).
|
||||
|
||||
## Situação e alertas (`alertas.py`)
|
||||
|
||||
@ -44,7 +46,7 @@ Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensaç
|
||||
|
||||
**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: antes de tudo, numa passada própria anterior à ordem por data, os **pares de mesmo valor** (título × pagamento, desempate igual ao `_pareia_exato` do motor: data mais próxima, depois pagamento após o título; sugestão "Mesmo valor do título de ..."); em seguida, também em passada própria, os **pares de valor próximo** dentro da tolerância da conciliação (`analisa_conta(..., tolerancia=)`, a mesma do cabeçalho: R$ 10 ou 2%, o maior), 1:1, menor diferença e depois data mais próxima, com a sugestão "Possível pagamento do título de ..., com R$ 6,20 a menos (possível desconto)" / "a mais (possível juros/multa)" dos dois lados; depois o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela; se nenhum título bater assim, aceita o pagamento a até `TOLERANCIA_PERCENTUAL_PADRAO`% (2%) da parcela arredondada mais próxima, só o percentual, sem o piso fixo de R$ 10, e a sugestão diz a diferença; a sugestão sempre diz a quantidade de parcelas: "em 3x de R$ 552,27" / "em 4x de R$ 2.163,56, com R$ 0,01 a mais (juros/desconto?)"; ver `_parcela()` em `alertas.py`), senão o título mais antigo com saldo (sugestão fraca, e o texto diz isso: "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..."; com mais de um título, acrescenta "Soma: R$ X; saldo residual de R$ Y em aberto." ou "...; o pagamento excede em R$ Y.", sendo a soma do **saldo ainda disponível** de cada título na distribuição, não do valor cheio). Do lado do **título**, só aparece sugestão com base concreta (pedido do usuário, rodada 35 de `CHANGELOG.md` nesta pasta): par de mesmo valor ("Mesmo valor do pagamento de ..."), par de valor próximo ou pagamento(s) identificados como parcela dele ("Possível parcelamento: N pagamento(s) de parcela deste título, somando R$ X; R$ Y ainda em aberto."); ter sido abatido só pela ordem de antiguidade não gera sugestão nem `relacionados` no título, porque valia para quase todos. A distribuição 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.
|
||||
As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: antes de tudo, numa passada própria anterior à ordem por data, os **pares de mesmo valor** (título × pagamento, desempate igual ao `_pareia_exato` do motor: data mais próxima, depois pagamento após o título; sugestão "Mesmo valor do título de ..."); depois a **combinação exata** (`_combinacoes_exatas()`, era a etapa 3 do motor, mesma busca `_busca_combinacao` e mesmos limites: 2 a 6 itens, 120 dias, 12 candidatos mais próximos; pagamento nunca antes do título; o alvo recebe "Soma exata de N pagamentos/títulos:" com um por linha, e cada membro "Com outro(s) N-1 ..., soma exatamente o ..."); em seguida, também em passada própria, os **pares de valor próximo** dentro da tolerância da conciliação (`analisa_conta(..., tolerancia=)`, a mesma do cabeçalho: R$ 10 ou 2%, o maior), 1:1, menor diferença e depois data mais próxima, com a sugestão "Possível pagamento do título de ..., com R$ 6,20 a menos (possível desconto)" / "a mais (possível juros/multa)" dos dois lados; depois o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a `MAX_PARCELAS` (12), até 1 centavo por parcela; se nenhum título bater assim, aceita o pagamento a até `DIFERENCA_MAX_PARCELA` (R$ 0,10) da parcela arredondada mais próxima, só arredondamento, sem estimar juros (rodada 44 de `CHANGELOG.md` nesta pasta; antes eram até 24x e até 2% da parcela), e a sugestão diz a diferença; a sugestão sempre diz a quantidade de parcelas: "em 3x de R$ 552,27" / "em 4x de R$ 2.163,56, com R$ 0,01 a mais (arredondamento)"; ver `_parcela()` em `alertas.py`), senão os títulos mais antigos com saldo: se 2 ou mais deles, inteiros e até a data do pagamento, somam **exatamente** o pagamento, é a **quitação dos mais antigos** (era a etapa 4 do motor), sugestão concreta nos dois lados ("Soma exata dos N títulos mais antigos em aberto:" / "Com outro(s) N-1 título(s) mais antigo(s), soma exatamente o pagamento de ..."); fora isso, **nenhuma sugestão**: a ordem de antiguidade sozinha não justifica sugestão (pedido do usuário, rodada 45 de `CHANGELOG.md` nesta pasta; antes saía a sugestão fraca "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..." com soma e saldo residual), e serve só para achar o excedente. **Regra geral: toda sugestão precisa de base concreta** (mesmo valor, valor próximo, combinação exata, parcela, quitação exata dos mais antigos), nos dois lados. Os textos com vários itens vêm **em várias linhas** separadas por `\n`, um item por linha, exibidas assim no balão (`white-space: pre-line` em `.conc-tooltip`) e juntadas numa linha só na célula e no XLSX por `pidConcTextoEmLinha()`/`texto_em_linha()`, mesma regra nos dois). Do lado do **título**, só aparece sugestão com base concreta (pedido do usuário, rodada 35 de `CHANGELOG.md` nesta pasta): par de mesmo valor ("Mesmo valor do pagamento de ..."), par de valor próximo ou pagamento(s) identificados como parcela dele ("Possível parcelamento: N pagamento(s) de parcela deste título, somando R$ X; R$ Y ainda em aberto."); ter sido abatido só pela ordem de antiguidade não gera sugestão nem `relacionados` no título, porque valia para quase todos. **Pagamento antes do título não gera sugestão** (pedido do usuário, rodada 39 de `CHANGELOG.md` nesta pasta: "o pagamento dificilmente será realizado antes do crédito"): valor próximo e parcela só casam com título de data até a do pagamento, e na ordem de antiguidade os títulos posteriores ao pagamento só entram depois dos anteriores, apenas para a conta do excedente, sem aparecer no texto nem em `relacionados` (a "Soma" e o "excede em" do texto são só dos títulos citados). O **par de mesmo valor** continua aceitando pagamento antes do título, como o motor (adiantamento a fornecedor). A distribuição 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 |
|
||||
|---|---|---|
|
||||
@ -59,7 +61,7 @@ Alertas de conta: `saldo_divergente_arquivo`, `conta_transitoria`, `saldo_devedo
|
||||
|
||||
Status: `divergente` (algum alerta alto) > `atencao` (médio/baixo) > `conciliada` (nenhum alerta). **Só títulos em aberto, sem pagamento a conciliar, é `conciliada`** (pedido do usuário, rodada 34): o que falta ali é o pagamento acontecer, não uma análise. A situação "Pendente de pagamento" (`pendente`) deixou de existir, e o chip dela saiu da tela; o `resumo` gravado de uma conciliação antiga ainda pode ter a chave `pendente` até ela ser aberta de novo.
|
||||
|
||||
**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. **Ao abrir uma conciliação** (`retrieve`), a mesma sincronização roda em todas as contas e o `resumo` é regravado se mudou (pedido do usuário, para as conciliações anteriores à regra nova de "conciliada" também receberem a validação automática).
|
||||
**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; **só vale para conta sem ajuste manual** (pedido do usuário, rodada 42 de `CHANGELOG.md` nesta pasta): conta com qualquer registro no histórico de vinculações (vinculou, acrescentou ou desfez, `_conciliacao_contas_ajustadas()`) nunca é validada automaticamente, mesmo conciliada, e perde a validação automática que tinha; a validação feita por uma pessoa (endpoint `validar/`) nunca é mexida por essa regra. **Ao abrir uma conciliação** (`retrieve`), a mesma sincronização roda em todas as contas e o `resumo` é regravado se mudou (pedido do usuário, para as conciliações anteriores à regra nova de "conciliada" também receberem a validação automática).
|
||||
|
||||
**Revisão dos títulos antigos é outra validação, por fornecedor** (pedido do usuário): `vencidos_validados`/`vencidos_validados_por`/`vencidos_validados_em`/`vencidos_observacao` na conta (migração `0086`), gravados por `validar-vencidos/`. Independente de `validada`: validar uma não mexe na outra, e a validação automática nunca toca nesses campos. Não é zerada se os títulos antigos mudarem (ex.: um deles for vinculado depois); se o fornecedor deixar de ter título antigo, só sai da aba.
|
||||
|
||||
@ -77,7 +79,7 @@ Todas com `PermissaoApp("utilitarios", "conciliacao-fornecedores")`.
|
||||
| `/api/conciliacoes-fornecedores/{id}/` | GET/DELETE | detalhe completo (contas + lançamentos + vínculos + análise, ~1 MB para 2.500 lançamentos; reaplica a validação automática e regrava o `resumo` se mudou) / exclusão (apaga o arquivo depois do commit) |
|
||||
| `/api/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=` | GET | nome no Questor (`null` se não achou ou Questor fora) |
|
||||
| `/api/conciliacoes-fornecedores/{id}/exportar-xlsx/` | GET | planilha com abas Resumo, Pendências e Alertas |
|
||||
| `/api/conciliacoes-fornecedores-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}/vincular/` | POST | `{lancamentos: [ids]}`: vínculo manual; exige itens sem vínculo, da própria conta, com pelo menos um débito e um crédito. Créditos maiores que débitos **além da tolerância** da conciliação viram `tipo="parcial"` (pagamento parcial: a `diferenca` continua em aberto, com a data do crédito mais antigo do vínculo para o aging, e entra nos totais em aberto/vencido. Nas sugestões, **só por base concreta** (mesmo valor, valor próximo, parcela: "Possível parcela do saldo a pagar do título de 28/05/2026 (R$ 6.490,65 em aberto), em 3x de R$ 2.163,55"), **nunca pela ordem de antiguidade** (rodadas 38 e 41 de `CHANGELOG.md` nesta pasta; `_eh_saldo_parcial()`/`_ref_titulo()` em `alertas.py`)); dentro da tolerância, ou pago a maior, `tipo="manual"` (fecha, com alerta de diferença) |
|
||||
| `/api/conciliacoes-fornecedores-contas/{id}/validar/` | POST | `{validada, observacao}` |
|
||||
| `/api/conciliacoes-fornecedores-contas/{id}/validar-vencidos/` | POST | `{validada, observacao?}`: validação e observação dos títulos em aberto há mais de 3 meses do fornecedor (aba própria), independentes de `validar/` |
|
||||
| `/api/conciliacoes-fornecedores-contas/{id}/historico-vinculos/` | GET | histórico das ações manuais sobre vínculos da conta, mais recente primeiro (botão "i" do razão) |
|
||||
@ -100,10 +102,10 @@ Duas vistas na mesma página. **Histórico**: formulário de nova conciliação
|
||||
- nas sub-tabelas de lançamentos, **histórico, contrapartida e sugestão ficam em uma linha cada**, cortados com reticências e com o texto completo num tooltip no visual do Portal (`[data-conc-tooltip]` + `.conc-tooltip`, único elemento `fixed` no body, só aparece quando o texto está cortado; **nunca `title` nativo**, pedido do usuário) (`pidConcHistoricoHtml()`/`pidConcSituacaoHtml()`; `max-width: 0` + largura em % nas colunas Histórico/Situação), para a altura da linha não variar com a largura da tela.
|
||||
- **"i" pequeno em cada linha vinculada** do razão (ao lado de "Desfazer"): abre o detalhe do vínculo (`pidConcAbreDetalheVinculo()`, só frontend, com o payload já carregado): tipo, automático/manual, o **critério** que o motor usou (`PID_CONC_CRITERIO_VINCULO`, texto por tipo; se mudar uma regra do `motor.py`, atualizar o texto), os lançamentos com histórico/contrapartida e os totais de créditos/débitos com a diferença. Mesmo modal do histórico (`pidConcAbreModalInfo()`).
|
||||
- botão **"i"** no canto direito da barra do razão abre o modal "Histórico de vinculações" da conta (`pidConcAbreHistoricoVinculos()`, criado sob demanda no body): cada ação com quem, quando, tipo e os seq. envolvidos, com histórico e contrapartida de cada um (lidos na hora pelo id no endpoint, não gravados na cópia) e os acrescentados marcados, borda verde/roxa/vermelha para vinculou/acrescentou/desfez.
|
||||
- passar o mouse na **sugestão** destaca em dourado a própria linha e as do outro lado da sugestão (`situacao.relacionados` no payload, id negativo = saldo de vínculo parcial; classe `is-sugestao-grupo`), no mesmo espírito do destaque verde do vínculo. **Duplo clique na sugestão** marca a linha e as relacionadas não vinculadas (pelo `change` dos checkboxes, então soma e "Vincular selecionados" acompanham); se já estavam todas marcadas, desmarca. Por isso o clique simples dentro da sugestão não marca/desmarca a linha (dispararia duas vezes antes do duplo clique).
|
||||
- passar o mouse na **sugestão** destaca em dourado a própria linha e as do outro lado da sugestão (`situacao.relacionados` no payload; id negativo = saldo de vínculo parcial, que acende as linhas do vínculo e a do saldo; classe `is-sugestao-grupo`), no mesmo espírito do destaque verde do vínculo. **Duplo clique na sugestão** marca a linha e as relacionadas não vinculadas (pelo `change` dos checkboxes, então soma e "Vincular selecionados" acompanham); se já estavam todas marcadas, desmarca. Por isso o clique simples dentro da sugestão não marca/desmarca a linha (dispararia duas vezes antes do duplo clique).
|
||||
- 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 (e, enquanto houver alguma expandida, as demais linhas de fornecedor ganham o fundo `--bg-canvas` (no `tr`, mesmo tom do destaque do Relatório Contábil) e o conteúdo apagado (`opacity: 0.35` no `td`, com fundo transparente), fora do hover; só CSS via `:has()`): 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.
|
||||
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 (e, enquanto houver alguma expandida, as demais linhas de fornecedor ganham o fundo `--bg-canvas` (no `tr`, mesmo tom do destaque do Relatório Contábil) e o conteúdo apagado (`opacity: 0.35` no `td`, com fundo transparente), fora do hover; só CSS via `:has()`): 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; cada **pagamento parcial** ganha, logo depois do último lançamento do vínculo no razão, logo abaixo do **último crédito (título) do vínculo** no razão (sem crédito no período, abaixo do último lançamento dele), uma **linha temporária "Saldo a pagar do vínculo #N"** (`linhaSaldoParcialHtml()`, só frontend, a partir de `vinculo.diferenca`/`vinculo.situacao`): natureza "A pagar" dourada, "Pagos R$ X de R$ Y em títulos" na segunda linha, em aberto e situação/sugestão do saldo, sem data/seq./saldo do Questor, **selecionável** (rodada 41: `data-lanc-check` com o id negativo `-vinculo.id`; entra na soma da barra como crédito e, marcado, o botão da barra vira "Adicionar ao vínculo #N" e chama `adicionar/` com os demais selecionados, via `adicionarAoVinculo()`, a mesma função do "Adicionar selecionados" do bloco; só um saldo por vez; "marcar todos" não marca saldos; o duplo clique na sugestão marca também o saldo citado), fundo dourado discreto com borda tracejada, destacada junto com o vínculo no hover e **não** escondida por "Ocultar conciliados"; some sozinha quando o vínculo fecha ou é desfeito), 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
|
||||
|
||||
|
||||
@ -32,10 +32,23 @@ from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import ROUND_DOWN, Decimal
|
||||
|
||||
from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA, TOLERANCIA_PERCENTUAL_PADRAO, Tolerancia
|
||||
from .motor import (
|
||||
JANELA_DIAS_COMBINACAO,
|
||||
MAX_CANDIDATOS_COMBINACAO,
|
||||
MAX_ITENS_COMBINACAO,
|
||||
TIPO_MANUAL,
|
||||
TIPO_PARCIAL,
|
||||
TIPO_TOLERANCIA,
|
||||
ItemConciliacao,
|
||||
Tolerancia,
|
||||
_busca_combinacao,
|
||||
)
|
||||
|
||||
MESES_VENCIDO = 3
|
||||
MAX_PARCELAS = 24
|
||||
# Parcela sugerida: até 12x, com diferença só de arredondamento (pedido do
|
||||
# usuário: 23x com R$ 4,77 de "juros" não é sugestão que faça sentido).
|
||||
MAX_PARCELAS = 12
|
||||
DIFERENCA_MAX_PARCELA = Decimal("0.10")
|
||||
|
||||
# Contas que não deveriam carregar saldo (não são fornecedores de fato).
|
||||
# Comparado sem acento, por trecho do nome da conta.
|
||||
@ -134,11 +147,12 @@ def _parcela(valor_titulo: Decimal, valor_pagamento: Decimal) -> tuple[int, Deci
|
||||
|
||||
Diferença zero: bate com o arredondamento da divisão (até 1 centavo por
|
||||
parcela no total). Diferença não zero: o pagamento fica a até
|
||||
`TOLERANCIA_PERCENTUAL_PADRAO`% da parcela arredondada mais próxima
|
||||
(possível juros/desconto), e a sugestão mostra quanto (pedido do usuário:
|
||||
um pagamento de R$ 2.163,57 de um título de R$ 8.654,22 em 4 parcelas
|
||||
não era reconhecido como parcela). Só o percentual, sem o piso fixo de
|
||||
`TOLERANCIA_VALOR_PADRAO`, que em parcela pequena aceitaria quase tudo.
|
||||
`DIFERENCA_MAX_PARCELA` da parcela arredondada mais próxima, e a sugestão
|
||||
mostra quanto (pedido do usuário: um pagamento de R$ 2.163,57 de um título
|
||||
de R$ 8.654,22 em 4 parcelas não era reconhecido como parcela). Era até
|
||||
2% da parcela; o usuário apontou que estimar juros para achar uma parcela
|
||||
(23x de R$ 264,64 com R$ 4,77 a mais) não faz sentido, então só centavos
|
||||
de arredondamento, e no máximo `MAX_PARCELAS` parcelas.
|
||||
"""
|
||||
if valor_pagamento <= 0 or valor_pagamento >= valor_titulo:
|
||||
return None
|
||||
@ -150,15 +164,84 @@ def _parcela(valor_titulo: Decimal, valor_pagamento: Decimal) -> tuple[int, Deci
|
||||
parcela_baixa = (valor_titulo / k).quantize(Decimal("0.01"), rounding=ROUND_DOWN)
|
||||
referencia = min((parcela_baixa, parcela_baixa + Decimal("0.01")), key=lambda p: abs(valor_pagamento - p))
|
||||
diferenca = valor_pagamento - referencia
|
||||
if abs(diferenca) <= referencia * TOLERANCIA_PERCENTUAL_PADRAO / Decimal(100):
|
||||
if abs(diferenca) <= DIFERENCA_MAX_PARCELA:
|
||||
return k, referencia, diferenca
|
||||
return None
|
||||
|
||||
|
||||
def texto_em_linha(texto: str) -> str:
|
||||
"""Junta numa linha só uma sugestão escrita em várias (uma por título, no
|
||||
balão da tela): "; " entre itens, espaço depois de linha terminada em ":"
|
||||
ou ".". Usado no XLSX; `pidConcTextoEmLinha()` faz o mesmo na tela."""
|
||||
partes = [p.strip() for p in texto.split("\n") if p.strip()]
|
||||
if not partes:
|
||||
return ""
|
||||
resultado = partes[0]
|
||||
for parte in partes[1:]:
|
||||
resultado += (" " if resultado.endswith((":", ".")) else "; ") + parte
|
||||
return resultado
|
||||
|
||||
|
||||
def _rotulo_titulo(credito: ItemAnalise) -> str:
|
||||
return f"{credito.data:%d/%m/%Y} ({_moeda(credito.valor)})"
|
||||
|
||||
|
||||
def _eh_saldo_parcial(credito: ItemAnalise) -> bool:
|
||||
"""Saldo de um pagamento parcial vinculado (id negativo = -id do vínculo)."""
|
||||
return credito.id < 0
|
||||
|
||||
|
||||
def _ref_titulo(credito: ItemAnalise) -> str:
|
||||
""""do título de ..." na sugestão de um pagamento; para o saldo de um
|
||||
pagamento parcial, deixa claro que é o que falta daquele vínculo."""
|
||||
if _eh_saldo_parcial(credito):
|
||||
return f"do saldo a pagar do título de {credito.data:%d/%m/%Y} ({_moeda(credito.valor)} em aberto)"
|
||||
return f"do título de {_rotulo_titulo(credito)}"
|
||||
|
||||
|
||||
def _combinacoes_exatas(
|
||||
creditos: list[ItemAnalise], debitos: list[ItemAnalise], usados: set[int]
|
||||
) -> list[tuple[ItemAnalise, list[ItemAnalise]]]:
|
||||
"""Combinação exata como sugestão (era a etapa 3 do motor, que deixou de
|
||||
vincular sozinha a pedido do usuário): para cada item ainda livre
|
||||
(títulos primeiro, por data), 2 a `MAX_ITENS_COMBINACAO` itens da
|
||||
natureza oposta que somem exatamente o valor dele, entre os
|
||||
`MAX_CANDIDATOS_COMBINACAO` mais próximos em data, na janela de
|
||||
`JANELA_DIAS_COMBINACAO` dias. Pagamento nunca antes do título (mesmo
|
||||
critério do valor próximo). Devolve (alvo, grupo) e marca `usados`."""
|
||||
grupos: list[tuple[ItemAnalise, list[ItemAnalise]]] = []
|
||||
for alvo in [*creditos, *debitos]:
|
||||
if alvo.id in usados:
|
||||
continue
|
||||
opostos = debitos if alvo.natureza == "C" else creditos
|
||||
candidatos = [
|
||||
i
|
||||
for i in opostos
|
||||
if i.id not in usados
|
||||
and i.valor < alvo.valor
|
||||
and abs((i.data - alvo.data).days) <= JANELA_DIAS_COMBINACAO
|
||||
and (i.data >= alvo.data if alvo.natureza == "C" else i.data <= alvo.data)
|
||||
]
|
||||
if len(candidatos) < 2:
|
||||
continue
|
||||
candidatos.sort(key=lambda i: (abs((i.data - alvo.data).days), i.data, i.id))
|
||||
candidatos = candidatos[:MAX_CANDIDATOS_COMBINACAO]
|
||||
convertidos = [ItemConciliacao(indice=i.id, data=i.data, natureza=i.natureza, valor=i.valor) for i in candidatos]
|
||||
alvo_centavos = int((alvo.valor * 100).to_integral_value())
|
||||
achado = _busca_combinacao(alvo_centavos, convertidos, MAX_ITENS_COMBINACAO)
|
||||
if not achado:
|
||||
continue
|
||||
ids = {c.indice for c in achado}
|
||||
grupo = sorted((i for i in candidatos if i.id in ids), key=lambda i: (i.data, i.id))
|
||||
usados.update(ids | {alvo.id})
|
||||
grupos.append((alvo, grupo))
|
||||
return grupos
|
||||
|
||||
|
||||
def _rotulo_pagamento(debito: ItemAnalise) -> str:
|
||||
return f"{debito.data:%d/%m/%Y} ({_moeda(debito.valor)})"
|
||||
|
||||
|
||||
def _natureza_diferenca(diferenca: Decimal) -> str:
|
||||
"""`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)"
|
||||
@ -201,10 +284,41 @@ def _analisa_pendentes(
|
||||
creditos_pareados.add(credito.id)
|
||||
saldo_sugerido[credito.id] = Decimal("0")
|
||||
|
||||
# 1,5º: combinação exata (vários pagamentos = um título, ou vários títulos
|
||||
# = um pagamento). Era vínculo automático do motor; o usuário pediu que
|
||||
# vire só sugestão. Fica reservada como o par de mesmo valor.
|
||||
sugestao_combinacao: dict[int, tuple[str, list[int]]] = {} # id -> (texto, relacionados)
|
||||
for alvo, grupo in _combinacoes_exatas(creditos, debitos, creditos_pareados | set(par_exato)):
|
||||
membros = [alvo, *grupo]
|
||||
if alvo.natureza == "C":
|
||||
nome_grupo, nome_outro = "pagamentos", "pagamento(s)"
|
||||
rotulo_alvo = _ref_titulo(alvo).removeprefix("do ")
|
||||
rotulos = [_rotulo_pagamento(d) for d in grupo]
|
||||
else:
|
||||
nome_grupo, nome_outro = "títulos", "título(s)"
|
||||
rotulo_alvo = f"pagamento de {_rotulo_pagamento(alvo)}"
|
||||
rotulos = [_rotulo_titulo(c) for c in grupo]
|
||||
sugestao_combinacao[alvo.id] = (
|
||||
"\n".join([f"Soma exata de {len(grupo)} {nome_grupo}:", *rotulos, "Vincule para confirmar."]),
|
||||
[i.id for i in grupo],
|
||||
)
|
||||
for item in grupo:
|
||||
sugestao_combinacao[item.id] = (
|
||||
f"Com outro(s) {len(grupo) - 1} {nome_outro}, soma exatamente o {rotulo_alvo}. Vincule para confirmar.",
|
||||
[i.id for i in membros if i is not item],
|
||||
)
|
||||
for item in membros:
|
||||
if item.natureza == "C":
|
||||
creditos_pareados.add(item.id)
|
||||
saldo_sugerido[item.id] = Decimal("0")
|
||||
|
||||
# 2º: valor próximo, dentro da tolerância da conciliação (possível
|
||||
# juros/desconto). Antes era vínculo automático do motor; o usuário pediu
|
||||
# que vire só sugestão, apontando a possibilidade de juros/desconto. 1:1,
|
||||
# menor diferença primeiro, depois data mais próxima.
|
||||
# menor diferença primeiro, depois data mais próxima. Só pagamento na data
|
||||
# do título ou depois: um valor apenas parecido pago antes do título não
|
||||
# indica nada (pedido do usuário; o par de mesmo valor acima ainda aceita
|
||||
# o adiantamento, como o motor).
|
||||
par_proximo: dict[int, ItemAnalise] = {} # id do débito -> título
|
||||
if tolerancia:
|
||||
candidatos_proximos = sorted(
|
||||
@ -214,6 +328,8 @@ def _analisa_pendentes(
|
||||
if c.id not in creditos_pareados
|
||||
for d in debitos
|
||||
if d.id not in par_exato
|
||||
and d.id not in sugestao_combinacao
|
||||
and d.data >= c.data
|
||||
and abs(c.valor - d.valor) * 100 <= tolerancia.limite_centavos(int(c.valor * 100))
|
||||
),
|
||||
key=lambda t: t[:4],
|
||||
@ -227,11 +343,21 @@ def _analisa_pendentes(
|
||||
titulo_proximo = {credito.id: debito_id for debito_id, credito in par_proximo.items()}
|
||||
titulo_exato = {credito.id: debito_id for debito_id, credito in par_exato.items()}
|
||||
por_id_debito = {d.id: d for d in debitos}
|
||||
por_id_credito = {c.id: c for c in creditos}
|
||||
# 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).
|
||||
parcelas_do_titulo: dict[int, list[int]] = {c.id: [] for c in creditos}
|
||||
# Títulos de uma quitação exata dos mais antigos: id -> (texto, relacionados)
|
||||
quitacao_do_titulo: dict[int, tuple[str, list[int]]] = {}
|
||||
|
||||
for debito in debitos:
|
||||
if debito.id in sugestao_combinacao:
|
||||
texto, relacionados = sugestao_combinacao[debito.id]
|
||||
total_nao_vinculado += debito.valor
|
||||
situacoes[debito.id] = SituacaoItem(
|
||||
CATEGORIA_NAO_VINCULADO, Decimal("0"), "Pagamento não vinculado.", texto, relacionados
|
||||
)
|
||||
continue
|
||||
if debito.id in par_proximo:
|
||||
credito = par_proximo[debito.id]
|
||||
diferenca = credito.valor - debito.valor
|
||||
@ -240,7 +366,7 @@ def _analisa_pendentes(
|
||||
CATEGORIA_NAO_VINCULADO,
|
||||
Decimal("0"),
|
||||
"Pagamento não vinculado.",
|
||||
f"Possível pagamento do título de {_rotulo_titulo(credito)}, com {_moeda(diferenca)} "
|
||||
f"Possível pagamento {_ref_titulo(credito)}, com {_moeda(diferenca)} "
|
||||
f"{_natureza_diferenca(diferenca)}. Vincule para confirmar.",
|
||||
[credito.id],
|
||||
)
|
||||
@ -252,68 +378,98 @@ def _analisa_pendentes(
|
||||
CATEGORIA_NAO_VINCULADO,
|
||||
Decimal("0"),
|
||||
"Pagamento não vinculado.",
|
||||
f"Mesmo valor do título de {_rotulo_titulo(credito)}. Vincule para confirmar.",
|
||||
f"Mesmo valor {_ref_titulo(credito)}. Vincule para confirmar.",
|
||||
[credito.id],
|
||||
)
|
||||
continue
|
||||
|
||||
restante = debito.valor
|
||||
usados: list[int] = []
|
||||
# Parcela exata (arredondamento) antes da com diferença, em qualquer título.
|
||||
# Parcela exata (arredondamento) antes da com diferença, em qualquer
|
||||
# título até a data do pagamento (parcela paga antes do título existir
|
||||
# não faz sentido, mesmo critério do valor próximo).
|
||||
candidatas = [
|
||||
(c, parcela)
|
||||
for c in creditos
|
||||
if saldo_sugerido[c.id] >= debito.valor and (parcela := _parcela(c.valor, debito.valor))
|
||||
if c.data <= debito.data
|
||||
and saldo_sugerido[c.id] >= debito.valor
|
||||
and (parcela := _parcela(c.valor, debito.valor))
|
||||
]
|
||||
escolhida = next((p for p in candidatas if p[1][2] == 0), candidatas[0] if candidatas else None)
|
||||
parcela_de = escolhida[0] if escolhida else None
|
||||
ordem = ([parcela_de] if parcela_de else []) + [c for c in creditos if c is not parcela_de]
|
||||
disponivel_usado = Decimal("0") # saldo dos títulos usados antes deste pagamento
|
||||
# Títulos até a data do pagamento primeiro; os posteriores só entram
|
||||
# para a conta do excedente (adiantamento), nunca na sugestão, porque
|
||||
# pagamento antes do título é improvável (pedido do usuário).
|
||||
# O saldo de pagamento parcial só entra por base concreta (mesmo
|
||||
# valor, valor próximo ou parcela, acima), nunca pela ordem de
|
||||
# antiguidade (pedido do usuário, rodada 38 do CHANGELOG).
|
||||
ordem = (
|
||||
([parcela_de] if parcela_de else [])
|
||||
+ [c for c in creditos if c is not parcela_de and not _eh_saldo_parcial(c) and c.data <= debito.data]
|
||||
+ [c for c in creditos if c is not parcela_de and not _eh_saldo_parcial(c) and c.data > debito.data]
|
||||
)
|
||||
disponivel: dict[int, Decimal] = {} # saldo de cada título usado, antes deste pagamento
|
||||
for credito in ordem:
|
||||
if restante <= 0:
|
||||
break
|
||||
if saldo_sugerido[credito.id] <= 0:
|
||||
continue
|
||||
disponivel_usado += saldo_sugerido[credito.id]
|
||||
disponivel[credito.id] = saldo_sugerido[credito.id]
|
||||
abatido = min(restante, saldo_sugerido[credito.id])
|
||||
saldo_sugerido[credito.id] -= abatido
|
||||
restante -= abatido
|
||||
usados.append(credito.id)
|
||||
|
||||
usados = [i for i in usados if por_id_credito[i].data <= debito.data]
|
||||
titulos = [c for c in creditos if c.id in usados]
|
||||
disponivel_usado = sum((disponivel[c.id] for c in titulos), Decimal("0"))
|
||||
if escolhida and usados == [parcela_de.id]:
|
||||
qtd_parcelas, referencia, diferenca = escolhida[1]
|
||||
parcelas_do_titulo[parcela_de.id].append(debito.id)
|
||||
# Quantidade de parcelas no texto (pedido do usuário).
|
||||
if diferenca == 0:
|
||||
sugestao = (
|
||||
f"Possível parcela do título de {_rotulo_titulo(parcela_de)}, em {qtd_parcelas}x de "
|
||||
f"Possível parcela {_ref_titulo(parcela_de)}, em {qtd_parcelas}x de "
|
||||
f"{_moeda(debito.valor)}. Vincule para confirmar."
|
||||
)
|
||||
else:
|
||||
sentido = "a mais" if diferenca > 0 else "a menos"
|
||||
sugestao = (
|
||||
f"Possível parcela do título de {_rotulo_titulo(parcela_de)}, em {qtd_parcelas}x de "
|
||||
f"{_moeda(referencia)}, com {_moeda(diferenca)} {sentido} (juros/desconto?). Vincule para confirmar."
|
||||
f"Possível parcela {_ref_titulo(parcela_de)}, em {qtd_parcelas}x de "
|
||||
f"{_moeda(referencia)}, com {_moeda(diferenca)} {sentido} (arredondamento). Vincule para confirmar."
|
||||
)
|
||||
elif titulos:
|
||||
# Sem par de valor nem parcela: só a ordem de antiguidade, sugestão
|
||||
# fraca, e o texto deixa isso claro (pedido do usuário).
|
||||
sugestao = (
|
||||
"Sem título de valor correspondente; o(s) mais antigo(s) em aberto: "
|
||||
f"{', '.join(_rotulo_titulo(c) for c in titulos)}."
|
||||
elif (
|
||||
len(titulos) >= 2
|
||||
and disponivel_usado == debito.valor
|
||||
and all(disponivel[c.id] == c.valor for c in titulos)
|
||||
):
|
||||
# Quitação dos títulos mais antigos: a soma dos mais antigos em
|
||||
# aberto (inteiros, até a data do pagamento) fecha exatamente o
|
||||
# pagamento. Era vínculo automático do motor (etapa 4); o usuário
|
||||
# pediu que vire sugestão, junto com a combinação. Base concreta:
|
||||
# os títulos também ganham sugestão.
|
||||
sugestao = "\n".join(
|
||||
[
|
||||
f"Soma exata dos {len(titulos)} títulos mais antigos em aberto:",
|
||||
*(_rotulo_titulo(c) for c in titulos),
|
||||
"Vincule para confirmar.",
|
||||
]
|
||||
)
|
||||
for c in titulos:
|
||||
quitacao_do_titulo[c.id] = (
|
||||
f"Com outro(s) {len(titulos) - 1} título(s) mais antigo(s), soma exatamente o pagamento de "
|
||||
f"{_rotulo_pagamento(debito)}. Vincule para confirmar.",
|
||||
[debito.id, *(o.id for o in titulos if o is not c)],
|
||||
)
|
||||
# Vários títulos: soma e o que sobra (pedido do usuário). A soma é
|
||||
# do saldo ainda disponível de cada um, que pode ser menor que o
|
||||
# valor do título se um pagamento anterior já o abateu em parte.
|
||||
if len(titulos) > 1:
|
||||
if restante > 0:
|
||||
residual = f"o pagamento excede em {_moeda(restante)}"
|
||||
else:
|
||||
residual = f"saldo residual de {_moeda(disponivel_usado - debito.valor)} em aberto"
|
||||
sugestao += f" Soma: {_moeda(disponivel_usado)}; {residual}."
|
||||
else:
|
||||
# Sem par de valor, combinação, parcela nem quitação exata: a ordem
|
||||
# de antiguidade sozinha não justifica sugestão nenhuma (pedido do
|
||||
# usuário: "quando não houver justificativa para a sugestão, ela
|
||||
# não deve ser realizada"). Antes saía "Sem título de valor
|
||||
# correspondente; o(s) mais antigo(s) em aberto: ...". A
|
||||
# distribuição acima continua valendo só para o excedente.
|
||||
sugestao = ""
|
||||
usados = []
|
||||
|
||||
if restante > 0:
|
||||
total_excedente += restante
|
||||
@ -335,7 +491,11 @@ def _analisa_pendentes(
|
||||
# Só sugestão com base concreta (mesmo valor, valor próximo ou parcela).
|
||||
# Ter sido abatido pela ordem de antiguidade não indica nada sobre este
|
||||
# título e valia para todos, então não vira sugestão (pedido do usuário).
|
||||
if credito.id in titulo_proximo:
|
||||
if credito.id in sugestao_combinacao:
|
||||
sugestao, pagos = sugestao_combinacao[credito.id]
|
||||
elif credito.id in quitacao_do_titulo:
|
||||
sugestao, pagos = quitacao_do_titulo[credito.id]
|
||||
elif credito.id in titulo_proximo:
|
||||
debito = por_id_debito[titulo_proximo[credito.id]]
|
||||
diferenca = credito.valor - debito.valor
|
||||
pagos = [debito.id]
|
||||
@ -350,7 +510,8 @@ def _analisa_pendentes(
|
||||
elif parcelas_do_titulo[credito.id]:
|
||||
pagos = parcelas_do_titulo[credito.id]
|
||||
soma = sum((por_id_debito[i].valor for i in pagos), Decimal("0"))
|
||||
sugestao = f"Possível parcelamento: {len(pagos)} pagamento(s) de parcela deste título, somando {_moeda(soma)}"
|
||||
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)}"
|
||||
restante_titulo = credito.valor - soma
|
||||
sugestao += f"; {_moeda(restante_titulo)} ainda em aberto." if restante_titulo > 0 else "."
|
||||
else:
|
||||
@ -383,9 +544,11 @@ def analisa_conta(
|
||||
`saldo_final_arquivo` o último impresso pelo Questor. Credor positivo.
|
||||
`tolerancia` (a da conciliação) só alimenta a sugestão de valor próximo."""
|
||||
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.
|
||||
# O saldo de cada pagamento parcial vinculado à mão continua em aberto e
|
||||
# disponível para vínculo: entra como um título a mais (id negativo = -id
|
||||
# do vínculo, com a data do título original para o aging). Nas sugestões,
|
||||
# só por base concreta (mesmo valor, valor próximo, parcela), nunca pela
|
||||
# ordem de antiguidade (pedidos do usuário, rodadas 38 e 41 do CHANGELOG).
|
||||
for vinculo in vinculos:
|
||||
if vinculo.tipo == TIPO_PARCIAL and vinculo.diferenca > 0 and vinculo.data_titulo:
|
||||
pendentes.append(
|
||||
|
||||
@ -14,11 +14,18 @@ ordem:
|
||||
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
|
||||
3. ~~Combinação exata~~ **deixou de ser vínculo automático em 2026-09-29**
|
||||
(pedido do usuário): 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. **Quitação dos títulos mais antigos**: para cada pagamento ainda
|
||||
Hoje é só **sugestão**, em `alertas.py`, que reaproveita
|
||||
`_busca_combinacao` e esses limites. `TIPO_COMBINACAO`/`_combina`
|
||||
continuam existindo pelos vínculos gravados antes disso.
|
||||
4. ~~Quitação dos títulos mais antigos~~ **deixou de ser vínculo automático
|
||||
em 2026-09-29**, junto com a combinação (pedido do usuário); hoje é
|
||||
sugestão em `alertas.py`, e `_quita_mais_antigos`/`TIPO_SEQUENCIA` ficam
|
||||
pelos vínculos gravados antes. Era: para cada pagamento ainda
|
||||
pendente, em ordem de data, se os títulos mais antigos em aberto (com
|
||||
data até a do pagamento), somados em sequência, fecham **exatamente** o
|
||||
pagamento, viram um vínculo. Não é um subconjunto qualquer (há uma única
|
||||
@ -28,7 +35,13 @@ ordem:
|
||||
a combinação não achava, por os títulos estarem fora dos 12 mais
|
||||
próximos em data.
|
||||
5. **Compensação do segmento fechado**: o que sobrou num segmento que zerou
|
||||
o saldo se compensa por definição; vira um único vínculo.
|
||||
o saldo se compensa por definição; vira um único vínculo. Continua
|
||||
automática (decisão do usuário, 2026-09-29), e desde então absorve o que
|
||||
antes a combinação e a quitação dos mais antigos vinculavam nesses
|
||||
segmentos.
|
||||
|
||||
Hoje, portanto, só o valor exato 1:1 e a compensação do segmento fechado
|
||||
vinculam sozinhos.
|
||||
A diferença dentro da tolerância (possível juros/desconto) **não** vira
|
||||
vínculo automático (pedido do usuário, 2026-09-25: antes era a etapa 5, 1:1
|
||||
ou 2 itens no último segmento): o par fica pendente e `alertas.py` o aponta
|
||||
@ -297,8 +310,9 @@ def concilia(itens: list[ItemConciliacao]) -> list[VinculoSugerido]:
|
||||
continue
|
||||
aberto = posicao == len(segmentos) - 1
|
||||
locais = _pareia_exato(segmento)
|
||||
locais += _combina(_sem_vinculo(segmento, locais), TIPO_COMBINACAO, MAX_ITENS_COMBINACAO, None)
|
||||
locais += _quita_mais_antigos(_sem_vinculo(segmento, locais))
|
||||
# A combinação exata (etapa 3) e a quitação dos títulos mais antigos
|
||||
# (etapa 4) não vinculam mais sozinhas (pedido do usuário,
|
||||
# 2026-09-29): viram sugestão em `alertas.py`.
|
||||
restantes = _sem_vinculo(segmento, locais)
|
||||
if not aberto and restantes:
|
||||
locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes))
|
||||
|
||||
@ -5305,19 +5305,29 @@ def _conciliacao_analisa(
|
||||
)
|
||||
|
||||
|
||||
def _conciliacao_contas_ajustadas(conciliacao: ConciliacaoFornecedor) -> set[int]:
|
||||
"""Contas com alguma ação manual sobre vínculo (vinculou, acrescentou ou
|
||||
desfez), pelo histórico de vinculações."""
|
||||
return set(
|
||||
ConciliacaoFornecedorVinculoHistorico.objects.filter(conta__conciliacao=conciliacao).values_list("conta_id", flat=True)
|
||||
)
|
||||
|
||||
|
||||
def _conciliacao_sincroniza_validacao_automatica(
|
||||
conta: ConciliacaoFornecedorConta, analise: conciliacao_alertas.AnaliseConta
|
||||
conta: ConciliacaoFornecedorConta, analise: conciliacao_alertas.AnaliseConta, ajustada_manualmente: bool
|
||||
) -> 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 com situação "Conciliada" já nasce validada (pedido do usuário),
|
||||
mas só se ninguém mexeu nos vínculos dela: conta que ficou conciliada por
|
||||
ajuste manual (vincular, acrescentar ou desfazer) é validada à mão
|
||||
(pedido do usuário). A validação automática é a que tem `validada_por`
|
||||
nulo: é retirada se a conta deixa de estar conciliada ou passa a ter
|
||||
ajuste manual; a feita por uma pessoa nunca é mexida aqui. Devolve se
|
||||
mudou (sem salvar)."""
|
||||
automatica = analise.status == conciliacao_alertas.STATUS_CONCILIADA and not ajustada_manualmente
|
||||
if automatica 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:
|
||||
if not automatica and conta.validada and conta.validada_por_id is None:
|
||||
conta.validada, conta.validada_em = False, None
|
||||
return True
|
||||
return False
|
||||
@ -5476,10 +5486,11 @@ def _conciliacao_calcula_resumo(carregado: list[_ConciliacaoContaCarregada]) ->
|
||||
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."""
|
||||
ajustadas = _conciliacao_contas_ajustadas(conciliacao)
|
||||
alteradas = [
|
||||
conta
|
||||
for conta, _, _, analise in _conciliacao_carrega(conciliacao)
|
||||
if _conciliacao_sincroniza_validacao_automatica(conta, analise)
|
||||
if _conciliacao_sincroniza_validacao_automatica(conta, analise, conta.id in ajustadas)
|
||||
]
|
||||
ConciliacaoFornecedorConta.objects.bulk_update(alteradas, ["validada", "validada_por", "validada_em"])
|
||||
return len(alteradas)
|
||||
@ -5500,7 +5511,9 @@ def _conciliacao_resposta_conta(conta: ConciliacaoFornecedorConta, sincroniza_va
|
||||
conciliacao = conta.conciliacao
|
||||
conta = ConciliacaoFornecedorConta.objects.select_related("validada_por", "vencidos_validados_por").get(pk=conta.pk)
|
||||
[carregada] = _conciliacao_carrega(conciliacao, [conta])
|
||||
if sincroniza_validacao and _conciliacao_sincroniza_validacao_automatica(conta, carregada[3]):
|
||||
if sincroniza_validacao and _conciliacao_sincroniza_validacao_automatica(
|
||||
conta, carregada[3], conta.historico_vinculos.exists()
|
||||
):
|
||||
conta.save(update_fields=["validada", "validada_por", "validada_em"])
|
||||
resumo = _conciliacao_atualiza_resumo(conciliacao)
|
||||
return Response({"conta": _conciliacao_conta_payload(*carregada), "resumo": resumo})
|
||||
@ -5634,7 +5647,11 @@ def _conciliacao_gera_xlsx(conciliacao: ConciliacaoFornecedor) -> bytes:
|
||||
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,
|
||||
situacao=(
|
||||
f"{situacao.descricao} Sugestão: {conciliacao_alertas.texto_em_linha(situacao.sugestao)}"
|
||||
if situacao.sugestao
|
||||
else situacao.descricao
|
||||
),
|
||||
)
|
||||
)
|
||||
for alerta in analise.alertas:
|
||||
@ -5686,7 +5703,12 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
|
||||
# títulos em aberto" passou a ser conciliada): ao abrir, reaplica a
|
||||
# validação automática, como na criação (pedido do usuário). A feita
|
||||
# por uma pessoa nunca é mexida.
|
||||
alteradas = [conta for conta, _, _, analise in carregado if _conciliacao_sincroniza_validacao_automatica(conta, analise)]
|
||||
ajustadas = _conciliacao_contas_ajustadas(conciliacao)
|
||||
alteradas = [
|
||||
conta
|
||||
for conta, _, _, analise in carregado
|
||||
if _conciliacao_sincroniza_validacao_automatica(conta, analise, conta.id in ajustadas)
|
||||
]
|
||||
resumo = _conciliacao_calcula_resumo(carregado)
|
||||
if alteradas:
|
||||
ConciliacaoFornecedorConta.objects.bulk_update(alteradas, ["validada", "validada_por", "validada_em"])
|
||||
|
||||
@ -929,7 +929,9 @@ button.conc-kpi.is-active {
|
||||
font-size: 0.75rem;
|
||||
font-style: normal;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
/* respeita as quebras de linha da sugestão (um título por linha) */
|
||||
white-space: pre-line;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
animation: pidFadeIn 120ms ease-out;
|
||||
}
|
||||
@ -951,6 +953,12 @@ button.conc-kpi.is-active {
|
||||
background: rgba(var(--coral-rgb), 0.14);
|
||||
}
|
||||
|
||||
/* Linha temporária do saldo de um pagamento parcial, no razão. */
|
||||
.conc-natureza--saldo {
|
||||
color: var(--gold);
|
||||
background: rgba(var(--gold-rgb), 0.14);
|
||||
}
|
||||
|
||||
.conc-situacao {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
@ -1271,6 +1279,21 @@ button.conc-kpi.is-active {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Saldo a pagar de um pagamento parcial (linha temporária, não é lançamento
|
||||
do arquivo): fundo tracejado discreto para não parecer um lançamento. Não
|
||||
é escondida por "Ocultar conciliados", porque ainda está em aberto. */
|
||||
.pa-table .conc-subtabela--pendentes tbody tr.conc-linha--saldo-parcial td {
|
||||
background: rgba(var(--gold-rgb), 0.05);
|
||||
border-top: 1px dashed rgba(var(--gold-rgb), 0.35);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Selecionável: marcado, a barra acrescenta ao vínculo dele. */
|
||||
.pa-table .conc-subtabela--pendentes tbody tr.conc-linha--saldo-parcial.is-selecionado td {
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.pa-table .conc-subtabela--pendentes tbody tr.conc-linha--saldo-parcial.is-grupo td,
|
||||
.pa-table .conc-subtabela--pendentes tbody tr.conc-linha--vinculada.is-grupo td {
|
||||
background: rgba(47, 158, 110, 0.1);
|
||||
color: var(--text-primary);
|
||||
|
||||
@ -174,8 +174,8 @@ function pidConcAbreModalInfo(titulo, subtitulo) {
|
||||
// outras linhas no razão (podem estar longe). Tudo já vem no payload da conta.
|
||||
const PID_CONC_CRITERIO_VINCULO = {
|
||||
exato: "Um crédito e um débito de mesmo valor. Havendo mais de um candidato, fica o de data mais próxima (e, no empate, o pagamento feito depois do título).",
|
||||
combinacao: "Vários lançamentos cuja soma fecha exatamente (até 6 itens, dentro de 120 dias, entre os de data mais próxima).",
|
||||
sequencia: "O pagamento fecha exatamente com os títulos mais antigos que estavam em aberto, somados em sequência (com data até a do pagamento). Sem limite de itens, porque só existe uma sequência possível.",
|
||||
combinacao: "Vários lançamentos cuja soma fecha exatamente (até 6 itens, dentro de 120 dias, entre os de data mais próxima). Critério usado só em importações até 29/09/2026: hoje esse caso fica pendente, como sugestão.",
|
||||
sequencia: "O pagamento fecha exatamente com os títulos mais antigos que estavam em aberto, somados em sequência (com data até a do pagamento). Sem limite de itens, porque só existe uma sequência possível. Critério usado só em importações até 29/09/2026: hoje esse caso fica pendente, como sugestão.",
|
||||
saldo_zerado: "Trecho do razão em que o saldo da conta voltou a zero: o que sobrou nele se compensa por definição, então vira um único vínculo.",
|
||||
tolerancia: "Débito e crédito (ou dois itens) com uma diferença pequena, dentro da tolerância da conciliação (valor fixo ou percentual do título, o maior). Possível juros/desconto. Critério usado só em importações até 25/09/2026: hoje esse caso fica pendente, como sugestão.",
|
||||
manual: "Vinculado manualmente.",
|
||||
@ -259,12 +259,23 @@ function pidConcValorAbertoHtml(situacao) {
|
||||
return pidConcMoeda(situacao.valor_em_aberto);
|
||||
}
|
||||
|
||||
/** Sugestão escrita em várias linhas (uma por título, para o balão) numa
|
||||
* linha só, para a célula cortada com reticências: "; " entre itens, espaço
|
||||
* depois de linha terminada em ":" ou ".". Mesma regra de
|
||||
* `texto_em_linha()` em `alertas.py` (XLSX). */
|
||||
function pidConcTextoEmLinha(texto) {
|
||||
const partes = String(texto || "").split("\n").map((p) => p.trim()).filter(Boolean);
|
||||
return partes.reduce((acc, parte) => (acc ? acc + (/[:.]$/.test(acc) ? " " : "; ") + parte : parte), "");
|
||||
}
|
||||
|
||||
/** Célula de situação: o fato primeiro; a dedução da ferramenta, se houver,
|
||||
* numa linha à parte marcada como sugestão (nunca misturada ao fato). */
|
||||
* numa linha à parte marcada como sugestão (nunca misturada ao fato). O
|
||||
* balão mostra a sugestão com as quebras de linha (`white-space: pre-line`
|
||||
* em `.conc-tooltip`). */
|
||||
function pidConcSituacaoHtml(situacao) {
|
||||
if (!situacao) return '<td class="conc-situacao"></td>';
|
||||
const sugestao = situacao.sugestao
|
||||
? `<span class="conc-sugestao" data-conc-tooltip="${pidConcEscapeAttr(situacao.sugestao)}" data-conc-relacionados="${(situacao.relacionados || []).join(",")}"><span class="conc-sugestao-tag">Sugestão</span>${pidConcEscape(situacao.sugestao)}</span>`
|
||||
? `<span class="conc-sugestao" data-conc-tooltip="${pidConcEscapeAttr(situacao.sugestao)}" data-conc-relacionados="${(situacao.relacionados || []).join(",")}"><span class="conc-sugestao-tag">Sugestão</span>${pidConcEscape(pidConcTextoEmLinha(situacao.sugestao))}</span>`
|
||||
: "";
|
||||
return `<td class="conc-situacao conc-situacao--${situacao.categoria}">${pidConcEscape(situacao.descricao)}${sugestao}</td>`;
|
||||
}
|
||||
@ -923,13 +934,62 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
tabela.classList.toggle("is-ocultando-conciliados", Boolean(estado.ocultarConciliados));
|
||||
tabela.innerHTML = `
|
||||
<thead><tr><th class="conc-subtabela__check"><input type="checkbox" data-lanc-todos aria-label="Marcar ou desmarcar todos os não conciliados" title="Marcar todos" /></th><th>Data</th><th>Seq.</th><th>Histórico</th><th>Natureza</th><th class="conc-num">Valor</th><th class="conc-num">Saldo</th><th class="conc-num">Em aberto</th><th>Situação</th></tr></thead>
|
||||
<tbody>${razao.map((l) => linhaRazaoHtml(l)).join("")}</tbody>`;
|
||||
<tbody>${linhasRazaoHtml()}</tbody>`;
|
||||
secaoPend.appendChild(tabela);
|
||||
} else {
|
||||
secaoPend.insertAdjacentHTML("beforeend", '<p class="conc-sem-pendencias">Todos os lançamentos desta conta estão vinculados.</p>');
|
||||
}
|
||||
painel.appendChild(secaoPend);
|
||||
|
||||
// Linha temporária do saldo a pagar de um pagamento parcial (pedido do
|
||||
// usuário): logo abaixo do último crédito (título) do vínculo no razão,
|
||||
// com a diferença que continua em aberto. Sem crédito no razão, abaixo do
|
||||
// último lançamento do vínculo. Não é lançamento do arquivo (sem seleção
|
||||
// nem saldo); some sozinha quando o vínculo fecha ou é desfeito.
|
||||
function linhasRazaoHtml() {
|
||||
const ultimoDoParcial = new Map();
|
||||
const temCredito = new Set();
|
||||
razao.forEach((l) => {
|
||||
if (!idsParciais.has(l.vinculo_id)) return;
|
||||
if (l.natureza === "C") {
|
||||
ultimoDoParcial.set(l.vinculo_id, l.id);
|
||||
temCredito.add(l.vinculo_id);
|
||||
} else if (!temCredito.has(l.vinculo_id)) {
|
||||
ultimoDoParcial.set(l.vinculo_id, l.id);
|
||||
}
|
||||
});
|
||||
return razao
|
||||
.map((l) => {
|
||||
const linha = linhaRazaoHtml(l);
|
||||
return ultimoDoParcial.get(l.vinculo_id) === l.id ? linha + linhaSaldoParcialHtml(vinculoPorId.get(l.vinculo_id)) : linha;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function linhaSaldoParcialHtml(vinculo) {
|
||||
const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id);
|
||||
const creditos = itens.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
|
||||
const debitos = itens.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0);
|
||||
const numero = numeroVinculo.get(vinculo.id) || "?";
|
||||
const situacao = vinculo.situacao || { categoria: "em_aberto", valor_em_aberto: vinculo.diferenca, descricao: "Saldo do pagamento parcial", sugestao: "" };
|
||||
// Selecionável (pedido do usuário): com o saldo marcado, a barra
|
||||
// acrescenta os demais selecionados a este vínculo em vez de criar outro.
|
||||
// Id negativo (-id do vínculo), o mesmo das sugestões.
|
||||
const selecionado = estado.selecionados.has(-vinculo.id);
|
||||
return `
|
||||
<tr class="conc-linha--saldo-parcial${selecionado ? " is-selecionado" : ""}" data-vinculo-id="${vinculo.id}" data-saldo-vinculo="${vinculo.id}">
|
||||
<td class="conc-subtabela__check"><input type="checkbox" data-lanc-check="${-vinculo.id}" data-saldo-check ${selecionado ? "checked" : ""} aria-label="Selecionar o saldo a pagar do vínculo #${numero}" /></td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
${pidConcHistoricoHtml({ historico: `Saldo a pagar do vínculo #${numero}`, contrapartida: `Pagos ${pidConcMoeda(debitos)} de ${pidConcMoeda(creditos)} em títulos` })}
|
||||
<td><span class="conc-natureza conc-natureza--saldo">A pagar</span></td>
|
||||
<td class="conc-num conc-valor">${pidConcMoeda(vinculo.diferenca)}</td>
|
||||
<td class="conc-num conc-valor conc-valor--saldo">—</td>
|
||||
<td class="conc-num conc-valor">${pidConcValorAbertoHtml(situacao)}</td>
|
||||
${pidConcSituacaoHtml(situacao)}
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function linhaRazaoHtml(lanc) {
|
||||
const comum = `
|
||||
<td>${pidConcData(lanc.data)}</td>
|
||||
@ -1006,8 +1066,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
sugestaoEl.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-sugestao-grupo")));
|
||||
sugestaoEl.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-sugestao-grupo")));
|
||||
// Duplo clique seleciona a linha e as relacionadas; se já estavam todas
|
||||
// selecionadas, desmarca (pedido do usuário). Só as não vinculadas
|
||||
// (com checkbox): o saldo de vínculo parcial fica só no destaque.
|
||||
// selecionadas, desmarca (pedido do usuário). Só as com checkbox: as não
|
||||
// vinculadas e a linha do saldo a pagar de um pagamento parcial.
|
||||
sugestaoEl.addEventListener("dblclick", (evento) => {
|
||||
evento.stopPropagation();
|
||||
if (window.getSelection) window.getSelection().removeAllRanges();
|
||||
@ -1059,34 +1119,51 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const todosCheck = secaoPend.querySelector("[data-lanc-todos]");
|
||||
const vincularBtn = secaoPend.querySelector("[data-vincular]");
|
||||
const porId = new Map(conta.lancamentos.map((l) => [l.id, l]));
|
||||
// O saldo a pagar de cada pagamento parcial entra na seleção como um
|
||||
// crédito a mais (id negativo = -id do vínculo), só para a soma da barra
|
||||
// e para escolher o vínculo que recebe os demais selecionados.
|
||||
conta.vinculos
|
||||
.filter((v) => v.tipo === "parcial")
|
||||
.forEach((v) => porId.set(-v.id, { id: -v.id, natureza: "C", valor: v.diferenca, saldoDoVinculo: v }));
|
||||
|
||||
function atualizarSelecao() {
|
||||
const selecionados = Array.from(estado.selecionados).map((id) => porId.get(id)).filter(Boolean);
|
||||
const lancamentos = selecionados.filter((l) => !l.saldoDoVinculo);
|
||||
const saldos = selecionados.filter((l) => l.saldoDoVinculo);
|
||||
const creditos = selecionados.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
|
||||
const debitos = selecionados.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0);
|
||||
const temAmbos = selecionados.some((l) => l.natureza === "C") && selecionados.some((l) => l.natureza === "D");
|
||||
if (saldos.length) {
|
||||
// Com um saldo marcado, a barra acrescenta ao vínculo dele.
|
||||
vincularBtn.textContent =
|
||||
saldos.length === 1 ? `Adicionar ao vínculo #${numeroVinculo.get(saldos[0].saldoDoVinculo.id) || ""}` : "Marque só um saldo a pagar";
|
||||
vincularBtn.disabled = saldos.length !== 1 || lancamentos.length === 0;
|
||||
} else {
|
||||
const temAmbos = lancamentos.some((l) => l.natureza === "C") && lancamentos.some((l) => l.natureza === "D");
|
||||
vincularBtn.textContent = "Vincular selecionados";
|
||||
vincularBtn.disabled = !temAmbos;
|
||||
}
|
||||
painel.querySelectorAll("[data-adicionar]").forEach((btn) => {
|
||||
btn.disabled = selecionados.length === 0;
|
||||
btn.disabled = lancamentos.length === 0;
|
||||
});
|
||||
if (todosCheck) {
|
||||
todosCheck.checked = selecionados.length > 0 && selecionados.length === pendentes.length;
|
||||
todosCheck.indeterminate = selecionados.length > 0 && selecionados.length < pendentes.length;
|
||||
todosCheck.title = selecionados.length ? "Desmarcar todos" : "Marcar todos";
|
||||
todosCheck.checked = lancamentos.length > 0 && lancamentos.length === pendentes.length;
|
||||
todosCheck.indeterminate = lancamentos.length > 0 && lancamentos.length < pendentes.length;
|
||||
todosCheck.title = lancamentos.length ? "Desmarcar todos" : "Marcar todos";
|
||||
}
|
||||
resumoSelecaoEl.innerHTML = selecionados.length
|
||||
? `Créditos <strong>${pidConcMoeda(creditos)}</strong> · Débitos <strong>${pidConcMoeda(debitos)}</strong> · Diferença <strong>${pidConcMoeda(creditos - debitos)}</strong>`
|
||||
: "Selecione débitos e créditos para vincular manualmente.";
|
||||
return { selecionados, diferenca: Math.round((creditos - debitos) * 100) / 100 };
|
||||
return { selecionados: lancamentos, saldos, diferenca: Math.round((creditos - debitos) * 100) / 100 };
|
||||
}
|
||||
atualizarSelecao();
|
||||
|
||||
// Cabeçalho: sem nada marcado, marca todos; com algum marcado (parcial ou
|
||||
// todos), desmarca todos.
|
||||
// todos), desmarca todos. "Todos" são os lançamentos; o saldo a pagar só
|
||||
// é desmarcado junto, nunca marcado (vale um vínculo por vez).
|
||||
if (todosCheck) {
|
||||
todosCheck.addEventListener("change", () => {
|
||||
const marcar = estado.selecionados.size === 0;
|
||||
secaoPend.querySelectorAll("[data-lanc-check]").forEach((check) => {
|
||||
secaoPend.querySelectorAll(marcar ? "[data-lanc-check]:not([data-saldo-check])" : "[data-lanc-check]").forEach((check) => {
|
||||
check.checked = marcar;
|
||||
check.closest("tr").classList.toggle("is-selecionado", marcar);
|
||||
const id = Number(check.dataset.lancCheck);
|
||||
@ -1100,7 +1177,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// A linha inteira marca/desmarca (pedido do usuário); o checkbox fica
|
||||
// como indicador. Clique no próprio checkbox segue o comportamento
|
||||
// nativo, e arrastar para selecionar texto não conta como clique.
|
||||
secaoPend.querySelectorAll(".conc-subtabela--pendentes tbody tr[data-lanc-id]:not([data-vinculo-id])").forEach((tr) => {
|
||||
secaoPend.querySelectorAll(".conc-subtabela--pendentes tbody tr[data-lanc-id]:not([data-vinculo-id]), .conc-subtabela--pendentes tbody tr[data-saldo-vinculo]").forEach((tr) => {
|
||||
tr.addEventListener("click", (evento) => {
|
||||
if (evento.target.closest("[data-lanc-check]")) return;
|
||||
// A sugestão é reservada ao duplo clique (seleciona o grupo); o
|
||||
@ -1123,8 +1200,44 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Acrescenta lançamentos a um vínculo manual/parcial (botão do bloco do
|
||||
// vínculo, ou a barra com o saldo a pagar dele marcado).
|
||||
async function adicionarAoVinculo(vinculo, novos, botao) {
|
||||
if (!novos.length) return;
|
||||
const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id);
|
||||
const assinado = (l) => (l.natureza === "C" ? Number(l.valor) : -Number(l.valor));
|
||||
const novaDiferenca = [...itens, ...novos].reduce((t, l) => t + assinado(l), 0);
|
||||
const arredondada = Math.round(novaDiferenca * 100) / 100;
|
||||
const efeito =
|
||||
arredondada > 0
|
||||
? `restarão ${pidConcMoeda(arredondada)} de diferença (em aberto, se passar da tolerância)`
|
||||
: arredondada < 0
|
||||
? `ficará pago a maior em ${pidConcMoeda(-arredondada)} (possível juros/multa)`
|
||||
: "o vínculo fecha sem diferença";
|
||||
const seguir = await pidConfirm(
|
||||
`Adicionar ${novos.length} lançamento(s) ao vínculo #${numeroVinculo.get(vinculo.id) || ""}? Com eles, ${efeito}.`,
|
||||
{ titulo: "Adicionar ao vínculo", textoConfirmar: "Adicionar" }
|
||||
);
|
||||
if (!seguir) return;
|
||||
botao.disabled = true;
|
||||
try {
|
||||
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-vinculos/${vinculo.id}/adicionar/`, {
|
||||
method: "POST",
|
||||
body: { lancamentos: novos.map((l) => l.id) },
|
||||
});
|
||||
aplicarRespostaConta(resposta);
|
||||
} catch (erro) {
|
||||
botao.disabled = false;
|
||||
pidAlert(erro.message, { titulo: "Não foi possível adicionar" });
|
||||
}
|
||||
}
|
||||
|
||||
vincularBtn.addEventListener("click", async () => {
|
||||
const { selecionados, diferenca } = atualizarSelecao();
|
||||
const { selecionados, saldos, diferenca } = atualizarSelecao();
|
||||
if (saldos.length) {
|
||||
if (saldos.length === 1) adicionarAoVinculo(saldos[0].saldoDoVinculo, selecionados, vincularBtn);
|
||||
return;
|
||||
}
|
||||
// Mesmo critério do servidor (vincular/): créditos maiores que os
|
||||
// débitos além da tolerância = pagamento parcial, o título continua em
|
||||
// aberto pelo saldo.
|
||||
@ -1186,34 +1299,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
</table>`;
|
||||
const adicionarBtn = bloco.querySelector("[data-adicionar]");
|
||||
if (adicionarBtn) {
|
||||
adicionarBtn.addEventListener("click", async () => {
|
||||
const novos = Array.from(estado.selecionados).map((id) => porId.get(id)).filter(Boolean);
|
||||
if (!novos.length) return;
|
||||
const assinado = (l) => (l.natureza === "C" ? Number(l.valor) : -Number(l.valor));
|
||||
const novaDiferenca = [...itens, ...novos].reduce((t, l) => t + assinado(l), 0);
|
||||
const arredondada = Math.round(novaDiferenca * 100) / 100;
|
||||
const efeito =
|
||||
arredondada > 0
|
||||
? `restarão ${pidConcMoeda(arredondada)} de diferença (em aberto, se passar da tolerância)`
|
||||
: arredondada < 0
|
||||
? `ficará pago a maior em ${pidConcMoeda(-arredondada)} (possível juros/multa)`
|
||||
: "o vínculo fecha sem diferença";
|
||||
const seguir = await pidConfirm(
|
||||
`Adicionar ${novos.length} lançamento(s) a este vínculo? Com eles, ${efeito}.`,
|
||||
{ titulo: "Adicionar ao vínculo", textoConfirmar: "Adicionar" }
|
||||
);
|
||||
if (!seguir) return;
|
||||
adicionarBtn.disabled = true;
|
||||
try {
|
||||
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-vinculos/${vinculo.id}/adicionar/`, {
|
||||
method: "POST",
|
||||
body: { lancamentos: novos.map((l) => l.id) },
|
||||
});
|
||||
aplicarRespostaConta(resposta);
|
||||
} catch (erro) {
|
||||
adicionarBtn.disabled = false;
|
||||
pidAlert(erro.message, { titulo: "Não foi possível adicionar" });
|
||||
}
|
||||
adicionarBtn.addEventListener("click", () => {
|
||||
const novos = Array.from(estado.selecionados).map((id) => porId.get(id)).filter((l) => l && !l.saldoDoVinculo);
|
||||
adicionarAoVinculo(vinculo, novos, adicionarBtn);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user