#!/usr/bin/env ruby # frozen_string_literal: true # # Procura um jeito de ACHAR O PLANO sem adivinhar a data. # # ── O PROBLEMA ────────────────────────────────────────────────────────────── # Hoje Romaneios::PlanoDoDia pergunta "qual plano existe NESTA data?", então # alguém tem que acertar o dia em que o plano foi publicado. Quem usa a # ferramenta pensa por OPERAÇÃO ("lançou a UBS Sudeste"), não por data — e a # data do lançamento raramente é a que a pessoa lembra. # # O que queremos, na ordem de preferência: # A) listar os ÚLTIMOS N PLANOS (nome + data + id) e deixar o operador # escolher numa lista — é o que resolve de verdade; # B) na falta disso, ir da NF ao plano: o operador já tem uma nota do # romaneio, e `?search=` em /v1/routes/visits/ já funciona. Se o objeto # da visita carregar uma FK de rota/plano, a cadeia # NF -> visita -> rota -> plano resolve o mesmo problema sem endpoint novo. # # ── RESULTADO DA RODADA DE 28/08/2026 (contra a API real) ─────────────────── # ✔ `GET /v1/routes/plans/` EXISTE e resolve o problema: 138 planos, 185 KB, # 0,86 s, cada um com `name` — "EMAD SETEMBRO 2026", "UBS OESTE AGOSTO # 2026" — que é exatamente como o operador pensa. NÃO é documentado. # ⇒ A conclusão anterior, registrada no README ("não é possível buscar o # plano por nome"), ESTAVA ERRADA. O endpoint estava na lista de # candidatos do bin/sondar_plano_do_dia; o que faltou foi RODAR. # ✔ Campos do plano: id (uuid), name, start_date, end_date, created, modified, # status, is_cluster, fleet, routes[] (uuids), start_time, end_time. # ✔ CADEIA COMPLETA, verificada ponta a ponta: # /v1/routes/plans/ -> plano: name + routes[] # /v1/routes/routes/{uuid}/ -> vehicle (id), planned_date, total_visits # /v1/plans/routes/{uuid}/visits/ -> order, vehicle_id, reference (NF), title # /v1/routes/vehicles/ -> 634185 = GADE_057 (199 na frota) # É tudo o que o romaneio precisa, sem adivinhar data nenhuma. # ✔ Caminho pela NF também funciona: a visita traz `route` (uuid), e a rota # traz `plan` (uuid). NF -> visita -> rota -> plano fecha. # ✘ NENHUM filtro funciona em /v1/routes/plans/: `ordering`, `limit`, `page`, # `page_size`, `search`, `name`, `planned_date`, `planned_date__gte`, # `created_at__gte`, `status` — os dez devolveram os MESMOS 138 itens do # controle. ⇒ "os 5 mais recentes" se faz em Ruby, depois de baixar tudo # (SimpliRoute::Client#planos). Uma chamada de menos de 1 s, é barato. # ✘ `/v1/plans/`, `/v1/plans/list/`, `/v1/plans/plans/`, `/v1/plans/routes/` # -> 404. O prefixo é `/v1/routes/`, não `/v1/plans/` (que só serve # `/v1/plans/routes/{uuid}/visits/`). # ⚠️ `OPTIONS /v1/routes/visits/` devolve 500 — não insista, não é seu token. # # Rode de novo se desconfiar que a API mudou. # # ── MÉTODO (as três regras que este script segue) ─────────────────────────── # 1. PARÂMETRO QUE NÃO EXISTE É IGNORADO EM SILÊNCIO. A API é Django REST: # filtro desconhecido devolve 200 + a lista inteira. "Veio 200 com # resultados" NÃO prova nada — já custou caro aqui (ver bin/sondar_busca_nf). # Por isso todo filtro é comparado com um CONTROLE sem o parâmetro. # 2. VALOR INVÁLIDO COMO ORÁCULO. É mais rápido e mais conclusivo que a # contagem: filtro que EXISTE reclama do valor (400 nomeando o campo); # filtro que não existe ignora e devolve 200. Uma chamada por candidato. # 3. OPTIONS ANTES DE CHUTAR. O DRF responde OPTIONS com os métodos permitidos # e, quando a rota é navegável, com o schema dos campos — é a forma mais # barata de saber o formato de um "plano" sem inventar nome de campo. # # ⚠️ SÓ LEITURA. Este script faz apenas GET e OPTIONS — não existe POST/PATCH/ # PUT/DELETE aqui, de propósito. A API é a de PRODUÇÃO do cliente. # # Uso (o token vive no ambiente, NUNCA no git): # # SIMPLIROUTE_TOKEN=xxx bin/sondar_planos --data 2026-08-31 # SIMPLIROUTE_TOKEN=xxx bin/sondar_planos --data 2026-08-31 --nf 90158 # SIMPLIROUTE_TOKEN=xxx bin/sondar_planos --data 2026-08-31 --limite 5 # # --data um dia em que EXISTE plano publicado (é a âncora de tudo). # --nf opcional: testa o caminho (B) partindo de uma nota conhecida. # --limite quantos planos queremos na listagem final (padrão 5). 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? data = nil nf = nil limite = 5 ARGV.each_with_index do |a, i| data = ARGV[i + 1] if a == '--data' nf = ARGV[i + 1] if a == '--nf' limite = ARGV[i + 1].to_i if a == '--limite' end sair('Uso: bin/sondar_planos --data [--nf ] [--limite 5]') if data.to_s.empty? limite = 5 if limite <= 0 # ── HTTP (GET e OPTIONS, só) ──────────────────────────────────────────────── def requisicao(caminho, metodo: :get, aceita: 'application/json') uri = URI.join(BASE, caminho) req = metodo == :options ? Net::HTTP::Options.new(uri) : Net::HTTP::Get.new(uri) req['Authorization'] = "Token #{TOKEN}" req['Accept'] = aceita 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, 300] end [res.code.to_i, corpo, res.body.to_s.bytesize, Time.now - t0, res] rescue StandardError => e [0, "ERRO: #{e.class}: #{e.message}", 0, Time.now - t0, nil] end # A resposta pode ser lista crua OU paginada ({count, next, results}). 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#{'─' * 74}\n#{t}\n#{'─' * 74}" end def linha(caminho, codigo, bytes, seg, extra = nil) puts format(' %-58s HTTP %-3s %7s %5.2fs%s', caminho[0, 58], codigo, "#{bytes}B", seg, extra ? " #{extra}" : '') end CHAVES_NOME = /name|title|label|descri/i.freeze CHAVES_ELO = /^(route|plan|plan_id|route_id|plan_uuid)$/i.freeze puts "SimpliRoute · sondagem de PLANOS base=#{BASE} data-âncora=#{data}" puts 'Somente GET/OPTIONS. Nada é alterado.' # ════════════════════════════════════════════════════════════════════════════ # [1] A VISITA APONTA PARA O PLANO? (caminho B — o mais promissor) # ════════════════════════════════════════════════════════════════════════════ # Se a visita carregar uma FK de rota/plano, o operador nunca mais precisa saber # a data: ele cola uma NF do romaneio e o sistema descobre o plano. titulo('[1] a VISITA carrega elo para rota/plano?') caminho_visitas = "/v1/routes/visits/?planned_date=#{data}" caminho_visitas += "&search=#{nf}" if nf cod, corpo, bytes, seg = requisicao(caminho_visitas) linha(caminho_visitas, cod, bytes, seg) visita = itens(corpo).first elos = {} if visita.is_a?(Hash) puts " chaves da visita: #{visita.keys.sort.join(', ')}" elos = visita.select { |k, v| k.to_s.match?(CHAVES_ELO) && !v.nil? && v.to_s.strip != '' } if elos.empty? puts ' => NENHUM campo route/plan preenchido. O caminho (B) morre aqui;' puts ' o que vale é a listagem de planos do bloco [3].' else puts ' => ELOS ENCONTRADOS (é por aqui que se chega ao plano):' elos.each { |k, v| puts " #{k} = #{v.inspect[0, 90]}" } end else puts ' => nenhuma visita nessa data/NF — escolha uma --data com plano publicado.' end # ── [1b] segue cada elo ───────────────────────────────────────────────────── # Não sabemos o prefixo da rota do recurso, então testamos os candidatos e # deixamos o HTTP responder. Quem devolver 200 com campo de nome resolve tudo. unless elos.empty? titulo('[1b] seguindo os elos — quem devolve nome/data do plano?') elos.each_value do |id| ["/v1/routes/routes/#{id}/", "/v1/plans/#{id}/", "/v1/plans/routes/#{id}/", "/v1/routes/#{id}/", "/v1/plans/routes/#{id}/visits/"].each do |c| cod, corpo, bytes, seg = requisicao(c) obj = corpo.is_a?(Hash) ? corpo : itens(corpo).first nomes = obj.is_a?(Hash) ? obj.select { |k, v| k.to_s.match?(CHAVES_NOME) && v.to_s.strip != '' } : {} linha(c, cod, bytes, seg, nomes.empty? ? nil : "nome: #{nomes.first.inspect[0, 60]}") next unless cod == 200 && obj.is_a?(Hash) puts " chaves: #{obj.keys.sort.join(', ')}" if nomes.any? end end end # ════════════════════════════════════════════════════════════════════════════ # [2] OPTIONS — o que cada rota admite, sem chutar campo # ════════════════════════════════════════════════════════════════════════════ titulo('[2] OPTIONS nos candidatos (métodos + schema, quando o DRF entrega)') %w[ /v1/plans/ /v1/plans/routes/ /v1/routes/plans/ /v1/routes/routes/ /v1/routes/visits/ ].each do |c| cod, corpo, bytes, seg, res = requisicao(c, metodo: :options) linha(c, cod, bytes, seg, res && res['Allow'] ? "Allow: #{res['Allow']}" : nil) next unless corpo.is_a?(Hash) campos = corpo.dig('actions', 'GET') || corpo.dig('actions', 'POST') puts " campos: #{campos.keys.sort.join(', ')}" if campos.is_a?(Hash) puts " filtros: #{corpo['filters'].inspect[0, 200]}" if corpo['filters'] end # ════════════════════════════════════════════════════════════════════════════ # [3] EXISTE LISTAGEM DE PLANOS? (caminho A — o que o operador quer) # ════════════════════════════════════════════════════════════════════════════ titulo('[3] procurando uma LISTAGEM de planos') candidatas = [ '/v1/plans/', '/v1/plans/list/', '/v1/plans/plans/', '/v1/routes/plans/', '/v1/plans/routes/', "/v1/plans/?planned_date=#{data}", "/v1/routes/plans/?planned_date=#{data}" ] listagens = [] candidatas.each do |c| cod, corpo, bytes, seg = requisicao(c) lista = itens(corpo) linha(c, cod, bytes, seg, lista.empty? ? nil : "#{total(corpo, lista)} itens") next if lista.empty? item = lista.first next unless item.is_a?(Hash) listagens << [c, corpo, lista] puts " chaves: #{item.keys.sort.join(', ')}" nomes = item.select { |k, v| k.to_s.match?(CHAVES_NOME) && v.to_s.strip != '' } if nomes.empty? puts ' => nenhum campo de nome PREENCHIDO (chave existir não basta)' else puts " => nomes: #{lista.first(5).filter_map { |i| i.is_a?(Hash) ? i[nomes.keys.first] : nil }.inspect}" end end # ════════════════════════════════════════════════════════════════════════════ # [4] ORÁCULO DO VALOR INVÁLIDO — quais filtros são REAIS # ════════════════════════════════════════════════════════════════════════════ # Filtro que existe reclama do valor (400 e costuma nomear o campo). Filtro que # não existe é ignorado: 200 e a mesma lista do controle. Uma chamada por # candidato — e o controle serve de régua para os que voltarem 200. if listagens.any? alvo = listagens.first[0].split('?').first titulo("[4] filtros REAIS em #{alvo} (oráculo do valor inválido + controle)") cod, corpo, bytes, seg = requisicao(alvo) controle = total(corpo, itens(corpo)) linha("#{alvo} (CONTROLE)", cod, bytes, seg, "#{controle} itens") %w[ordering limit page_size page planned_date planned_date__gte name search created_at__gte status].each do |param| c = "#{alvo}?#{param}=%25%25%25zz" cod, corpo, bytes, seg = requisicao(c) qtd = total(corpo, itens(corpo)) veredito = if cod == 400 then 'FILTRO EXISTE (reclamou do valor)' elsif cod == 200 && qtd == controle then 'ignorado (igual ao controle)' elsif cod == 200 then "mudou a contagem (#{qtd}) — investigar" else "HTTP #{cod}" end linha("?#{param}=", cod, bytes, seg, veredito) end # ── [4b] os ÚLTIMOS N PLANOS numa chamada só ───────────────────────────── # É o formato que a tela precisa: o operador usa a ferramenta quando lançou # uma operação, então o plano que ele quer está entre os últimos — uma lista # curta de nomes vence qualquer campo de data. titulo("[4b] dá para pedir os #{limite} planos mais recentes de uma vez?") [ "#{alvo}?ordering=-planned_date&limit=#{limite}", "#{alvo}?ordering=-created_at&limit=#{limite}", "#{alvo}?ordering=-id&page_size=#{limite}" ].each do |c| cod, corpo, bytes, seg = requisicao(c) lista = itens(corpo) resumo = lista.first(limite).filter_map do |i| next unless i.is_a?(Hash) nome = i.values_at(*i.keys.select { |k| k.to_s.match?(CHAVES_NOME) }).compact.first "#{nome || i['id']} (#{i['planned_date'] || i['created_at']})" end linha(c, cod, bytes, seg, "#{total(corpo, lista)} itens") puts " #{resumo.join(' | ')}" if resumo.any? end else titulo('[4] sem listagem de planos — nada para filtrar') puts ' Se o bloco [1] achou elo na visita, o caminho é a cadeia NF -> visita' puts ' -> rota -> plano. Se não achou nenhum dos dois, a conclusão antiga' puts ' ("não há como listar planos") se confirma e a saída é a planilha.' end # ════════════════════════════════════════════════════════════════════════════ # [5] API NAVEGÁVEL — o DRF às vezes desenha o formulário de filtros # ════════════════════════════════════════════════════════════════════════════ # Quando a rota é navegável, o HTML traz os nomes de filtro válidos escritos na # página. Só olhamos se aparece algo com cara de filtro; não baixamos a página. titulo('[5] API navegável (Accept: text/html) — pistas de filtro no HTML') ['/v1/plans/', '/v1/routes/visits/'].each do |c| cod, corpo, bytes, seg = requisicao(c, aceita: 'text/html') html = corpo.is_a?(String) ? corpo : corpo.to_s pistas = html.scan(/name="([a-z_]{3,30})"/i).flatten.uniq.first(12) linha(c, cod, bytes, seg, pistas.empty? ? 'sem formulário' : "campos: #{pistas.join(', ')}") end titulo('COMO LER ISTO') puts <<~FIM • Bloco [3] com itens e nome preenchido -> caminho (A): a tela vira uma lista dos #{limite} planos mais recentes, e a data some do formulário. • Bloco [1] com elo (route/plan) na visita -> caminho (B): o operador cola uma NF do romaneio e o sistema acha o plano. Resolve sem endpoint de listagem. • Os dois vazios -> a conclusão anterior está certa: não há como buscar plano por nome com token de conta, e o .xlsx continua sendo o plano B. NÃO altere Romaneios::PlanoDoDia antes de ter esta saída: a escada de queda de lá foi escrita justamente por não haver este dado. FIM