#!/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=` FUNCIONA (não documentado): dia inteiro cai de ~3,8 MB/9 s # para ~1 KB/0,6 s. É o que a tela usa hoje. # ✘ `reference`, `reference_id`, `q`, `title`, `reference__*` — IGNORADOS em # silêncio (200 + dia inteiro). # ✘ Intervalo (`planned_date_from/to`, `since/until`, `__gte/__lte`, # `date_from/to`) — TODOS ignorados: devolveram 2469, igual ao controle sem # parâmetro nenhum. # ✘ Sem `planned_date` a API devolve um conjunto padrão (~2469) que NÃO cobre # o histórico — `?search=` sozinho escondeu a visita mais antiga da NF. # ⇒ `planned_date` continua obrigatório; a busca por NF é dia a dia + search. # # Rode de novo se desconfiar que a API mudou. # # SÓ LEITURA: o script faz apenas GET/OPTIONS. Não altera nada. # # Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN): # # SIMPLIROUTE_TOKEN=xxx bin/sondar_busca_nf --nf 82891 --data 2026-07-17 # SIMPLIROUTE_TOKEN=xxx bin/sondar_busca_nf --nf 82891 --data 2026-07-17 --sem-data # # --data deve ser um dia em que a NF EXISTE (é a referência de comparação). # --sem-data testa a consulta sem `planned_date` nenhum; pode ser lenta ou vir # gigante, por isso fica de fora por padrão. # # ⚠️ Por que comparar contagens: a API é Django REST. Parâmetro que o backend # não registra é IGNORADO em silêncio — devolve 200 e a lista inteira. Então # "voltou 200 com resultados" NÃO prova que filtrou. A prova é o filtro DIMINUIR # o resultado do dia e sobrar só a NF pedida. require 'net/http' require 'json' require 'uri' require 'date' BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com') TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s def sair(msg) warn msg exit 1 end sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty? nf = nil data = nil sem_data = false ARGV.each_with_index do |a, i| nf = ARGV[i + 1] if a == '--nf' data = ARGV[i + 1] if a == '--data' sem_data = true if a == '--sem-data' end sair('Uso: bin/sondar_busca_nf --nf --data [--sem-data]') if nf.to_s.empty? || data.to_s.empty? def requisicao(caminho, metodo: :get) uri = URI.join(BASE, caminho) req = metodo == :options ? Net::HTTP::Options.new(uri) : Net::HTTP::Get.new(uri) req['Authorization'] = "Token #{TOKEN}" req['Accept'] = 'application/json' t0 = Time.now res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 10, read_timeout: 120) { |h| h.request(req) } corpo = begin res.body.to_s.empty? ? nil : JSON.parse(res.body) rescue JSON::ParserError res.body.to_s[0, 200] end [res.code.to_i, corpo, res.body.to_s.bytesize, Time.now - t0] rescue StandardError => e [0, "ERRO: #{e.class}: #{e.message}", 0, Time.now - t0] end # A resposta pode ser lista crua OU paginada ({count, next, results}). No caso # paginado o `count` já é o total — é a informação mais barata que existe aqui. def itens(corpo) return corpo if corpo.is_a?(Array) return Array(corpo['results']) if corpo.is_a?(Hash) && corpo.key?('results') [] end def total(corpo, lista) corpo.is_a?(Hash) && corpo['count'] ? corpo['count'] : lista.size end def titulo(t) puts "\n#{'─' * 70}\n#{t}\n#{'─' * 70}" end puts "Base: #{BASE} NF: #{nf} Data de referência: #{data}" # ── 1. Linha de base: o dia inteiro, do jeito que a tela faz hoje ──────────── titulo("1. LINHA DE BASE · GET /v1/routes/visits/?planned_date=#{data}") cod, corpo, bytes, seg = requisicao("/v1/routes/visits/?planned_date=#{data}") sair("Falhou (HTTP #{cod}): #{corpo.inspect}") unless cod == 200 base_lista = itens(corpo) base_total = total(corpo, base_lista) base_ids = base_lista.map { |v| v['id'] } da_nf = base_lista.select { |v| v['reference'].to_s == nf.to_s } puts " HTTP 200 em #{seg.round(2)}s — #{bytes} bytes" puts " visitas no dia .......... #{base_total}" puts " paginado? ............... #{corpo.is_a?(Hash) ? "sim (count=#{corpo['count']}, next=#{corpo['next'].inspect})" : 'não (lista crua)'}" puts " visitas com a NF #{nf} ... #{da_nf.size}" da_nf.each { |v| puts " id=#{v['id']} tracking=#{v['tracking_id'].inspect} status=#{v['status']} data=#{v['planned_date']}" } puts " ⚠️ tracking_id vem NULO na lista-por-data" if da_nf.any? && da_nf.all? { |v| v['tracking_id'].nil? } sair("A NF #{nf} não aparece em #{data}. Passe uma data em que ela exista — sem isso não há como comparar.") if da_nf.empty? # ── 2. Filtro por NF: o parâmetro DIMINUI o resultado do dia? ──────────────── # Se a contagem não mudar, o parâmetro foi ignorado (mesmo com HTTP 200). titulo('2. FILTRO POR NF · + =NF (diminuiu = filtro real)') %w[reference reference_id search q title reference__exact reference__icontains].each do |param| cod, corpo, _b, seg = requisicao("/v1/routes/visits/?planned_date=#{data}&#{param}=#{nf}") lista = itens(corpo) qtd = total(corpo, lista) veredito = if cod != 200 then "HTTP #{cod} — não aceito" elsif qtd == base_total then 'IGNORADO (mesma contagem do dia inteiro)' elsif qtd.zero? then 'aceito porém vazio — filtrou demais' elsif lista.all? { |v| v['reference'].to_s == nf.to_s } then "★ FILTRA DE VERDADE — #{qtd} visita(s), todas da NF" else "reduziu p/ #{qtd} mas veio NF de fora" end puts " #{param.ljust(22)} #{veredito} (#{seg.round(2)}s)" end # ── 3. Filtro por período ──────────────────────────────────────────────────── # Comparação: o intervalo .. tem que devolver MAIS que só . dia2 = (Date.parse(data) + 1).to_s titulo("3. FILTRO POR PERÍODO · #{data} a #{dia2} (> #{base_total} = intervalo respeitado)") [ %w[planned_date_from planned_date_to], %w[planned_date_after planned_date_before], %w[planned_date__gte planned_date__lte], %w[since until], %w[start_date end_date], %w[date_from date_to] ].each do |de, ate| cod, corpo, _b, seg = requisicao("/v1/routes/visits/?#{de}=#{data}&#{ate}=#{dia2}") lista = itens(corpo) qtd = total(corpo, lista) veredito = if cod != 200 then "HTTP #{cod} — não aceito" elsif qtd > base_total then "★ RESPEITA O INTERVALO — #{qtd} visitas (dia sozinho: #{base_total})" elsif qtd == base_total then "mesma contagem do dia — inconclusivo/ignorado" else "#{qtd} visitas — menos que o dia sozinho, estranho" end puts " #{"#{de}/#{ate}".ljust(38)} #{veredito} (#{seg.round(2)}s)" end # ── 4. OPTIONS: o DRF às vezes lista os filtros aceitos ────────────────────── titulo('4. OPTIONS /v1/routes/visits/ (metadados do endpoint)') cod, corpo, = requisicao('/v1/routes/visits/', metodo: :options) if cod == 200 && corpo.is_a?(Hash) puts " métodos ... #{corpo['renders'] ? corpo.slice('name', 'description', 'parses').inspect[0, 300] : corpo.keys.inspect}" filtros = corpo['filters'] || corpo['filter_fields'] || corpo.dig('actions', 'GET') puts " filtros ... #{filtros ? filtros.inspect[0, 500] : '(não declarados)'}" else puts " HTTP #{cod} — #{corpo.inspect[0, 200]}" end # ── 5. Sem planned_date nenhum — a pergunta direta ─────────────────────────── unless sem_data puts "\n(Pulei o teste SEM planned_date. Rode com --sem-data para incluir — pode demorar ou vir gigante.)" exit 0 end titulo('5. SEM planned_date · a API devolve a NF inteira de uma vez?') [ "/v1/routes/visits/?reference=#{nf}", "/v1/routes/visits/?search=#{nf}", '/v1/routes/visits/' ].each do |caminho| cod, corpo, bytes, seg = requisicao(caminho) lista = itens(corpo) qtd = total(corpo, lista) casam = lista.count { |v| v['reference'].to_s == nf.to_s } puts " GET #{caminho}" if cod == 200 puts " #{qtd} visita(s), #{casam} com a NF #{nf} — #{bytes} bytes em #{seg.round(2)}s" datas = lista.select { |v| v['reference'].to_s == nf.to_s }.map { |v| v['planned_date'] }.uniq puts " datas da NF: #{datas.inspect}" if datas.any? puts ' ★ RESOLVE TUDO: a NF veio inteira, sem varrer dia a dia' if casam > 1 && casam == qtd else puts " HTTP #{cod} — #{corpo.inspect[0, 200]}" end end