diff --git a/app/controllers/admin/edicao_lancamentos_controller.rb b/app/controllers/admin/edicao_lancamentos_controller.rb index 9877618..83e9c1b 100644 --- a/app/controllers/admin/edicao_lancamentos_controller.rb +++ b/app/controllers/admin/edicao_lancamentos_controller.rb @@ -33,6 +33,9 @@ class Admin::EdicaoLancamentosController < ApplicationController # # Com um único lançamento já vem o card montado, para não custar um segundo # round-trip no caso comum. + # + # Aceita ?de=&ate= para varrer um período (a API só filtra por UM dia, então + # varremos dia a dia). Sem período, usa as datas que o espelho conhece. def buscar authorize :edicao_lancamento @@ -40,15 +43,21 @@ class Admin::EdicaoLancamentosController < ApplicationController return render_erro('Informe o número da NF.') if nf.blank? entregas = Entrega.por_nf(nf).to_a - return render_erro("NF #{nf} não encontrada na base de rastreio.") if entregas.empty? + datas = datas_de_busca(entregas) + return render_erro(sem_datas_msg(nf, entregas)) if datas.empty? - ocorrencias = ocorrencias_da_nf(nf, entregas) + falhas = [] + ocorrencias = ocorrencias_da_nf(nf, entregas, datas, falhas) unica = ocorrencias.first if ocorrencias.one? && ocorrencias.first[:id].present? render json: { ok: true, ocorrencias: ocorrencias, - visita: (unica && payload_visita(unica[:id], entrega_de(entregas, unica[:tracking_id]))), + visita: (unica && payload_seguro(unica, entregas, falhas)), + periodo: { de: datas.first, ate: datas.last, dias: datas.size }, + # Dias que a API não respondeu. Vão para a tela: uma busca que devolve + # menos do que existe TEM que dizer que devolveu menos. + falhas: falhas, motivos: motivos_seguros } rescue SimpliRoute::NotFound => e @@ -152,18 +161,53 @@ class Admin::EdicaoLancamentosController < ApplicationController @client ||= SimpliRoute::Client.new end + # Máximo de dias varridos numa busca por período. A API só filtra por UM dia, + # então cada dia é uma chamada — com `search` cada uma custa ~0,6 s e elas vão + # em lotes paralelos, o que torna dois meses viável; o teto existe para a tela + # não virar uma varredura sem fim. + MAX_DIAS_VARREDURA = 62 + + # Quais dias consultar na API: + # • com ?de=/?ate= — o período pedido (o SimpliRoute duplica plano para + # outra data, e aí o espelho não tem como saber qual é); + # • sem período — as datas que o espelho conhece (barato: 1 ou 2 chamadas). + def datas_de_busca(entregas) + de = data_param(:de) + ate = data_param(:ate) + + if de || ate + inicio, fim = [de || ate, ate || de].minmax + (inicio..fim).first(MAX_DIAS_VARREDURA) + else + entregas.filter_map { |e| e.planned_date&.to_date }.uniq.sort + end + end + + def data_param(chave) + valor = params[chave].to_s.strip + return nil if valor.blank? + + Date.parse(valor) + rescue Date::Error + nil + end + + # Sem datas não há o que consultar — e o motivo muda a saída para o ADM. + def sem_datas_msg(nf, entregas) + if entregas.empty? + "NF #{nf} não encontrada na base de rastreio. Informe um período para procurar direto no SimpliRoute." + else + "NF #{nf} está na base de rastreio mas sem data planejada. Informe um período para procurar na API." + end + end + # Todos os lançamentos da NF, cruzando as DUAS fontes: # • espelho de rastreio (Entrega) — traz motorista/veículo; - # • API do SimpliRoute, nas datas desses lançamentos — pega a visita - # duplicada que a sincronização ainda não trouxe para o espelho. - # - # A API não tem busca global por NF (só por data), então as datas vêm do - # espelho: 1 chamada por data distinta (plano duplicado costuma cair no mesmo - # dia, e aí é 1 chamada só). - def ocorrencias_da_nf(nf, entregas) - datas = entregas.filter_map { |e| e.planned_date&.to_date }.uniq + # • API do SimpliRoute, nas datas varridas — pega a visita duplicada que a + # sincronização ainda não trouxe para o espelho. + def ocorrencias_da_nf(nf, entregas, datas, falhas) trackings = entregas.map { |e| e.tracking_id.to_s } - do_dia = datas.flat_map { |data| client.visitas_da_data(data) } + do_dia = visitas_dos_dias(datas, nf, falhas) visitas = do_dia.select { |v| v['reference'].to_s == nf || trackings.include?(v['tracking_id'].to_s) } .uniq { |v| v['id'] } @@ -176,13 +220,48 @@ class Admin::EdicaoLancamentosController < ApplicationController # ele existe em vez de sumir silenciosamente — que é o bug que estamos # corrigindo. vistos = visitas.map { |v| v['tracking_id'].to_s } - orfas = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) }.map { |e| ocorrencia_do_espelho(e) } + orfas = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) } + .map { |e| ocorrencia_do_espelho(e, datas.include?(e.planned_date&.to_date)) } # Mais recente primeiro (a data vem como texto ISO da API e como timestamp # do espelho — os 10 primeiros caracteres normalizam as duas). (achadas + orfas).sort_by { |o| [o[:data].to_s[0, 10], o[:id].to_i] }.reverse end + # Dias consultados de uma vez. Cada chamada abre a própria conexão HTTP (ver + # SimpliRoute::Client#requisicao) e não toca o banco, então dá para + # paralelizar em lotes; o lote pequeno evita martelar a API do SimpliRoute. + MAX_PARALELO_VARREDURA = 6 + + # Passa a NF como `search` — sem isso cada dia baixaria ~3,8 MB (o dia inteiro) + # e um mês seria inviável. Ver a nota em SimpliRoute::Client#visitas_da_data. + def visitas_dos_dias(datas, nf, falhas) + mutex = Mutex.new + + datas.each_slice(MAX_PARALELO_VARREDURA).flat_map do |lote| + lote.map { |data| Thread.new { visitas_do_dia(data, nf, falhas, mutex) } }.flat_map(&:value) + end + end + + # Um dia que a API não responde NÃO pode derrubar a busca inteira: registra a + # falha e segue com os outros dias. (Foi assim que uma NF com visitas em duas + # datas passou a não devolver nada em vez de devolver o que deu certo.) + def visitas_do_dia(data, nf, falhas, mutex) + client.visitas_da_data(data, busca: nf) + rescue SimpliRoute::Error => e + mutex.synchronize { falhas << { data: data.strftime('%d/%m/%Y'), erro: e.message } } + [] + end + + # Abrir o card do lançamento único é conveniência: se falhar, a lista ainda + # tem que aparecer. + def payload_seguro(ocorrencia, entregas, falhas) + payload_visita(ocorrencia[:id], entrega_de(entregas, ocorrencia[:tracking_id])) + rescue SimpliRoute::Error => e + falhas << { data: ocorrencia[:data].to_s[0, 10], erro: "não foi possível abrir o lançamento: #{e.message}" } + nil + end + def entrega_de(entregas, tracking_id) return nil if tracking_id.blank? @@ -233,7 +312,7 @@ class Admin::EdicaoLancamentosController < ApplicationController } end - def ocorrencia_do_espelho(entrega) + def ocorrencia_do_espelho(entrega, data_varrida) { id: nil, tracking_id: entrega.tracking_id, @@ -246,7 +325,11 @@ class Admin::EdicaoLancamentosController < ApplicationController veiculo: entrega.vehicle, editavel: false, no_painel: true, - aviso: 'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar' + aviso: (if data_varrida + 'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar' + else + 'Fora do período consultado — inclua a data deste lançamento no período para poder editá-lo' + end) } end diff --git a/app/services/simpli_route/client.rb b/app/services/simpli_route/client.rb index 49c87a7..5f59e78 100644 --- a/app/services/simpli_route/client.rb +++ b/app/services/simpli_route/client.rb @@ -41,8 +41,27 @@ module SimpliRoute end # Todas as visitas de uma data (Array de hashes). - def visitas_da_data(data) - Array(get("/v1/routes/visits/?planned_date=#{data.to_date.iso8601}")) + # + # `busca` vira `&search=` — parâmetro NÃO documentado, mas verificado contra + # a API real em 21/07/2026: filtra de verdade e derruba a resposta de um dia + # de ~3,8 MB / ~9 s (1879 visitas) para ~1 KB / ~0,6 s. Sem ele, varrer um + # mês é inviável. + # + # ⚠️ NÃO confiar só nele: `reference`, `reference_id`, `q` e `title` são + # ignorados em SILÊNCIO pela API (devolvem 200 com o dia inteiro). Se o + # `search` mudar de nome, cai no mesmo comportamento — por isso quem chama + # continua filtrando por `reference`, e a perda é só de desempenho. + # + # ⚠️ SEM `planned_date` a API responde um conjunto padrão (~2469 visitas, + # medido) que NÃO cobre o histórico: a NF 82891 voltava só com a visita de + # 21/07, escondendo a de 17/07. Todo filtro de intervalo testado + # (planned_date_from/to, since/until, __gte/__lte, date_from/to) é ignorado. + # Por isso a data continua obrigatória e a varredura é dia a dia. + def visitas_da_data(data, busca: nil) + caminho = "/v1/routes/visits/?planned_date=#{data.to_date.iso8601}" + caminho += "&search=#{URI.encode_www_form_component(busca.to_s)}" if busca.present? + + Array(get(caminho)) end # Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega diff --git a/app/views/admin/edicao_lancamentos/show.html.erb b/app/views/admin/edicao_lancamentos/show.html.erb index 41336fb..eb3b20a 100644 --- a/app/views/admin/edicao_lancamentos/show.html.erb +++ b/app/views/admin/edicao_lancamentos/show.html.erb @@ -40,8 +40,40 @@ Buscar + <%# Período — a API do SimpliRoute só filtra visita por UM dia, então achar + uma NF exige saber em quais dias procurar. Sem período, procuramos nas + datas que o painel já conhece; quando o plano foi duplicado para outra + data, o painel não sabe dela e é preciso ampliar aqui. %> +
+ Sem período, procuramos só nas datas que o painel conhece. Se o plano foi duplicado para outra + data, amplie o período — a API do SimpliRoute não busca NF por intervalo, então varremos dia a + dia (máx. 62 dias). +
Consultando SimpliRoute…
+ + <%# Lançamentos da NF — só aparece quando há mais de um. Plano duplicado no @@ -199,9 +231,16 @@ const loadEl = document.getElementById('busca-loading'); const cardEl = document.getElementById('card'); + const campoDe = document.getElementById('campo-de'); + const campoAte = document.getElementById('campo-ate'); + const periodoEl = document.getElementById('busca-periodo'); + const falhasEl = document.getElementById('busca-falhas'); + async function buscar() { const nf = campoNf.value.trim(); erroEl.classList.add('hidden'); + periodoEl.classList.add('hidden'); + falhasEl.classList.add('hidden'); if (!nf) { mostrarErroBusca('Informe o número da NF.'); return; } loadEl.classList.remove('hidden'); @@ -209,14 +248,18 @@ ocorrencias = []; selecionado = null; try { - const resp = await fetch(URLS.buscar + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } }); + let url = URLS.buscar + '?nf=' + encodeURIComponent(nf); + if (campoDe.value) url += '&de=' + encodeURIComponent(campoDe.value); + if (campoAte.value) url += '&ate=' + encodeURIComponent(campoAte.value); + const resp = await fetch(url, { headers: { 'Accept': 'application/json' } }); const data = await resp.json(); if (!data.ok) { renderOcorrencias(); mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; } ocorrencias = data.ocorrencias || []; + renderPeriodo(data.periodo, data.falhas); // Só veio o card pronto quando a NF tem um lançamento único; com mais de // um, o ADM escolhe na lista antes de abrir qualquer coisa. if (data.visita) { abrirCard(data.visita); } else { visita = null; renderOcorrencias(); } - if (!data.visita && !ocorrencias.length) mostrarErroBusca('Nenhum lançamento localizado para esta NF.'); + if (!data.visita && !ocorrencias.length) mostrarErroBusca('Nenhum lançamento localizado para esta NF no período consultado.'); } catch (e) { renderOcorrencias(); mostrarErroBusca('Erro de conexão ao buscar a NF.'); @@ -226,9 +269,37 @@ } function mostrarErroBusca(msg) { erroEl.textContent = msg; erroEl.classList.remove('hidden'); } + // Dizer o que foi consultado e o que falhou. Uma busca que devolve menos do + // que existe precisa admitir isso — senão parece que a NF não existe. + function renderPeriodo(periodo, falhas) { + if (periodo && periodo.dias) { + periodoEl.textContent = 'Consultado: ' + fmtData(periodo.de) + + (periodo.dias > 1 ? ' a ' + fmtData(periodo.ate) + ' (' + periodo.dias + ' dias)' : '') + '.'; + periodoEl.classList.remove('hidden'); + } + if (falhas && falhas.length) { + falhasEl.innerHTML = 'A API do SimpliRoute falhou em ' + falhas.length + + ' dia(s) — pode haver lançamento faltando na lista:
' + + falhas.map(f => '' + esc(f.data) + ' — ' + esc(f.erro) + '
').join(''); + falhasEl.classList.remove('hidden'); + } + } + document.getElementById('btn-buscar').addEventListener('click', buscar); campoNf.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } }); + document.getElementById('btn-30dias').addEventListener('click', () => { + const hoje = new Date(); + const trinta = new Date(hoje.getTime() - 30 * 86400000); + const iso = d => d.toISOString().slice(0, 10); + campoDe.value = iso(trinta); + campoAte.value = iso(hoje); + if (campoNf.value.trim()) buscar(); + }); + document.getElementById('btn-limpar-periodo').addEventListener('click', () => { + campoDe.value = ''; campoAte.value = ''; + }); + // ── Lista de lançamentos da NF ────────────────────────────── const ocEl = document.getElementById('ocorrencias'); const ocLista = document.getElementById('oc-lista'); diff --git a/bin/sondar_busca_nf b/bin/sondar_busca_nf new file mode 100755 index 0000000..774435c --- /dev/null +++ b/bin/sondar_busca_nf @@ -0,0 +1,198 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Descobre se a API do SimpliRoute aceita buscar visita por NF e/ou por período +# — a doc oficial só documenta `?planned_date=` de UM dia. +# +# ── RESULTADO DA RODADA DE 21/07/2026 (contra a API real) ─────────────────── +# ✔ `&search=