From 3e51d0e03b7b54fb1a68fb28093e152f17b5e948c62ae4f3e92c58bb647237f4 Mon Sep 17 00:00:00 2001 From: victor Date: Fri, 28 Aug 2026 17:03:37 -0300 Subject: [PATCH] =?UTF-8?q?,=20Corre=C3=A7oes=20na=20busca=20da=20API=20pa?= =?UTF-8?q?ra=20ver=20os=20ultimos=20planos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 49 ++- app/controllers/admin/romaneios_controller.rb | 44 ++- app/services/romaneios/plano_do_dia.rb | 98 +++++- app/services/simpli_route/client.rb | 60 +++- app/views/admin/romaneios/index.html.erb | 59 +++- bin/sondar_planos | 329 ++++++++++++++++++ 6 files changed, 603 insertions(+), 36 deletions(-) create mode 100755 bin/sondar_planos diff --git a/README.md b/README.md index 2c6f37e..8c94f2d 100644 --- a/README.md +++ b/README.md @@ -3329,17 +3329,46 @@ silenciosa que acerta é invisível, mas a que erra é indefensável. ## Sobre buscar o plano por nome na API -**Não é possível.** A documentação -(https://documentation.simpliroute.com) não expõe endpoint que **liste** planos — -só `POST /v1/plans/create-plan/` (onde o plano tem `name`), -`GET /v1/plans/{planned_date}/vehicles/` e `GET /v1/plans/routes/{PLAN_ID}/visits/`. -A lista de planos que aparece no site deles é da interface web. Por isso o nome é -**colado pelo operador** e casado localmente. +> ### ✅ CORRIGIDO EM 28/08/2026 — o "não é possível" abaixo estava ERRADO +> +> **`GET /v1/routes/plans/` existe** e devolve os planos com o `name` que a +> operação usa. Verificado contra a API real (`bin/sondar_planos`): **138 planos, +> 185 KB, 0,86 s**, nomes como `EMAD SETEMBRO 2026` e `UBS OESTE AGOSTO 2026`. +> Não é documentado. O endpoint até estava na lista de candidatos do +> `bin/sondar_plano_do_dia` — **o que faltou foi rodar o script**; a conclusão +> "não é possível" foi tirada só da documentação. +> +> A cadeia inteira fecha, sem adivinhar data nenhuma: +> +> | chamada | devolve | +> |---|---| +> | `GET /v1/routes/plans/` | `name`, `start_date`, `end_date`, `created`, `routes[]` | +> | `GET /v1/routes/routes/{uuid}/` | `vehicle` (id), `planned_date`, `total_visits`, `plan` | +> | `GET /v1/plans/routes/{uuid}/visits/` | `order`, `vehicle_id`, `reference` (NF), `title` | +> | `GET /v1/routes/vehicles/` | traduz `634185` → `GADE_057` | +> +> **Nenhum filtro funciona** nessa rota: `ordering`, `limit`, `page`, `page_size`, +> `search`, `name`, `planned_date`, `planned_date__gte`, `created_at__gte` e +> `status` devolveram os mesmos 138 itens do controle sem parâmetro. Então "os 5 +> mais recentes" é corte em Ruby depois de baixar tudo — `SimpliRoute::Client#planos`. +> Ordena por `created`, e não por `start_date`, porque a janela do plano é um +> INTERVALO (o EMAD SETEMBRO 2026 vai de 31/08 a 08/09) — era exatamente isso que +> tornava a data impossível de adivinhar. +> +> Pela NF também fecha: a visita traz `route` (uuid) e a rota traz `plan` (uuid). +> +> **Lição:** conclusão tirada de documentação não é conclusão. A própria memória +> desta integração já dizia que a API ignora parâmetro desconhecido em silêncio — +> ela também não anuncia as rotas que tem. -> `GET /v1/plans/{planned_date}/vehicles/` devolve veículos + UUIDs de rota por -> data — é o degrau (a) que o `Romaneios::PlanoDoDia` hoje tenta adivinhar. Vale -> checar com `bin/sondar_plano_do_dia`, que ganhou uma seção `[4]` procurando -> endpoint de planos com nome. +O texto original, mantido para contexto de como se chegou à conclusão errada: + +> **Não é possível.** A documentação +> (https://documentation.simpliroute.com) não expõe endpoint que **liste** planos — +> só `POST /v1/plans/create-plan/` (onde o plano tem `name`), +> `GET /v1/plans/{planned_date}/vehicles/` e `GET /v1/plans/routes/{PLAN_ID}/visits/`. +> A lista de planos que aparece no site deles é da interface web. Por isso o nome é +> **colado pelo operador** e casado localmente. ## 📂 Arquivos diff --git a/app/controllers/admin/romaneios_controller.rb b/app/controllers/admin/romaneios_controller.rb index 961ec95..612137f 100644 --- a/app/controllers/admin/romaneios_controller.rb +++ b/app/controllers/admin/romaneios_controller.rb @@ -17,6 +17,11 @@ class Admin::RomaneiosController < ApplicationController @romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50) @operacoes_agrupadas = Operacao.agrupadas_por_mes @data_padrao = Date.current + + # Os 5 planos mais recentes, para o operador ESCOLHER em vez de adivinhar a + # data. Best-effort de propósito: API fora do ar devolve [] e a tela cai no + # campo de data de sempre — a importação nunca fica bloqueada por isso. + @planos = SimpliRoute::Client.new.planos(limite: 5) # Nome ORIGINAL do logo escolhido (a descricao), para a tela mostrar o mesmo # que o operador enviou; nil = ainda usando o logo da GADE do repositório. @logo_atual = Configuracao.find_by(chave: 'romaneio_logo')&.descricao @@ -44,7 +49,12 @@ class Admin::RomaneiosController < ApplicationController @romaneio = preparar_romaneio return if performed? - plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date) + # `@plano_escolhido` vem de #preparar_romaneio quando o operador clicou num + # plano da lista; sem ele o caminho continua sendo por data. + # Os DOIS: a data já foi resolvida em #preparar_romaneio (não paga a chamada + # de novo) e ainda destrava a queda para o espelho local se a API cair no + # meio; o plano é quem delimita as rotas. + plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date, plano: @plano_escolhido) importar(plano.linhas, origem: plano.origem) rescue SimpliRoute::Error => e # NotFound herda de Error: a mensagem dele já diz para enviar o .xlsx. @@ -262,9 +272,39 @@ class Admin::RomaneiosController < ApplicationController # "importar de novo o mesmo dia" cair na reconciliação em vez de criar um segundo # romaneio — a chave única (planned_date, operacao_tabela) é a garantia disso. def preparar_romaneio + # ── Plano escolhido na lista (caminho normal desde 28/08/2026) ────────── + # O plano resolve de uma vez os três campos que antes eram digitados: a + # DATA (perguntada à API, porque a janela do plano não a revela), o NOME do + # plano (que ia colado à mão e é o que descobre a operação do mês) e as + # rotas, que delimitam o romaneio quando dois planos caem no mesmo dia. + @plano_escolhido = nil data = params[:planned_date].presence + + if params[:plano_id].present? + @plano_escolhido = SimpliRoute::Client.new.planos(limite: 20) + .find { |pl| pl['id'].to_s == params[:plano_id].to_s } + if @plano_escolhido.nil? + redirect_to admin_romaneios_path, + alert: 'Plano não encontrado no SimpliRoute — recarregue a página e escolha de novo.' + return nil + end + + # A data REAL do plano, não a janela dele. Sem isso o romaneio nasceria + # com `planned_date` errado e o "Reimportar" (que é por data) buscaria o + # dia errado depois. + resolvida = Romaneios::PlanoDoDia.new(plano: @plano_escolhido).data_resolvida + if resolvida.nil? + redirect_to admin_romaneios_path, + alert: "Não consegui descobrir o dia do plano \"#{@plano_escolhido['name']}\". " \ + 'Informe a data ou envie a planilha do plano (.xlsx).' + return nil + end + data = resolvida.to_s + params[:rotulo_plano] = @plano_escolhido['name'] if params[:rotulo_plano].blank? + end + if data.blank? - redirect_to admin_romaneios_path, alert: 'Informe a data do plano.' + redirect_to admin_romaneios_path, alert: 'Escolha um plano ou informe a data.' return nil end diff --git a/app/services/romaneios/plano_do_dia.rb b/app/services/romaneios/plano_do_dia.rb index c5d13b2..51eb96d 100644 --- a/app/services/romaneios/plano_do_dia.rb +++ b/app/services/romaneios/plano_do_dia.rb @@ -36,8 +36,23 @@ module Romaneios attr_reader :origem - def initialize(data:, client: nil) - @data = data.to_date + # Duas formas de pedir o plano, e a primeira é a boa: + # + # PlanoDoDia.new(plano: {...}) -> o PLANO escolhido na lista (o hash que + # SimpliRoute::Client#planos devolve) + # PlanoDoDia.new(data: ...) -> o jeito antigo, por data + # + # POR QUE O PLANO VENCE: o operador pensa por operação ("lançou a UBS + # Sudeste"), não por data — e a data era mesmo impossível de acertar, porque + # o plano tem uma JANELA e o dia das rotas ora é o começo dela, ora o fim + # (ver SimpliRoute::Client#rota). Com o plano em mãos a data deixa de ser + # chute: pergunta-se à API. + # + # A forma por data continua porque o "Reimportar" de um romaneio antigo só + # tem a data, e porque ela é o caminho para o espelho local quando a API cai. + def initialize(data: nil, plano: nil, client: nil) + @plano = plano + @data = data&.to_date @client = client @origem = nil end @@ -46,25 +61,90 @@ module Romaneios @linhas ||= carregar end + # O dia REAL do plano — o que o Romaneio grava em `planned_date`. Uma + # chamada: todas as rotas de um plano dividem o mesmo dia. + def data_resolvida + @data ||= begin + rota = cliente.rota(Array(@plano && @plano['routes']).first) + rota && rota['planned_date'] ? Date.parse(rota['planned_date']) : nil + rescue Date::Error, SimpliRoute::Error + nil + end + end + private + def cliente + @cliente ||= @client || SimpliRoute::Client.new + end + def carregar - linhas = da_api + linhas = @plano ? da_api_por_plano : da_api return linhas if linhas.any? - linhas = do_espelho - return linhas if linhas.any? + # O espelho é por DATA. Vindo de um plano, ele só entra depois que a data + # foi resolvida — sem ela não há o que consultar, e chamar `do_espelho` + # com @data nil traria o período inteiro. + linhas = do_espelho if @data + return linhas if linhas&.any? - raise SimpliRoute::NotFound, - "O plano de #{@data.strftime('%d/%m/%Y')} não está disponível no SimpliRoute " \ - 'nem no espelho de rastreio. Envie a planilha do plano (.xlsx).' + raise SimpliRoute::NotFound, mensagem_de_ausencia + end + + # A mensagem diz o que a pessoa escolheu, não um dado interno: quem clicou + # num plano na lista não reconhece a data resolvida, e quem digitou a data + # não sabe de plano nenhum. + def mensagem_de_ausencia + if @plano && @data.nil? + "Não consegui descobrir o dia do plano \"#{@plano['name']}\" no SimpliRoute. " \ + 'Escolha outro plano ou envie a planilha do plano (.xlsx).' + elsif @plano + "O plano \"#{@plano['name']}\" (#{@data.strftime('%d/%m/%Y')}) não tem paradas no " \ + 'SimpliRoute nem no espelho de rastreio. Envie a planilha do plano (.xlsx).' + else + "O plano de #{@data.strftime('%d/%m/%Y')} não está disponível no SimpliRoute " \ + 'nem no espelho de rastreio. Envie a planilha do plano (.xlsx).' + end + end + + # ── Caminho do PLANO ESCOLHIDO (o normal desde 28/08/2026) ─────────────── + # Três chamadas, todas verificadas contra a API real: + # + # 1. a 1ª rota do plano -> o DIA real (Client#rota) + # 2. visitas do dia -> 2047 visitas, 3 MB, ~9 s (Client#visitas_da_data) + # 3. veículos da conta -> traduz 634185 em GADE_057 (dentro de por_visitas) + # + # O FILTRO PELAS ROTAS DO PLANO É O PONTO DA COISA, não uma otimização: dois + # planos podem cair no MESMO dia (uma operação normal e uma de avulsas, por + # exemplo), e sem ele o romaneio de um sairia com as paradas do outro. A + # visita traz `route`, e o plano traz a lista de uuids de rota — o cruzamento + # é exato. Medido: 2047 visitas do dia, 73 rotas, todas com `route`, + # `vehicle` e `order` preenchidos (2047/2047). + # + # Depois do filtro é o mesmo `por_visitas` de sempre: ele já agrupa por + # veículo, resolve o nome (GADE_057) e ordena por `order`. + def da_api_por_plano + return [] unless SimpliRoute.configurado? + + data = data_resolvida + return [] if data.nil? + + do_plano = Array(@plano['routes']).map(&:to_s).to_set + visitas = cliente.visitas_da_data(data).select { |v| do_plano.include?(v['route'].to_s) } + return [] if visitas.empty? + + linhas = por_visitas(visitas, cliente) + @origem = 'api' if linhas.any? + linhas + rescue SimpliRoute::Error + [] end # ── (a) e (b) ──────────────────────────────────────────────────────────── def da_api return [] unless SimpliRoute.configurado? - cliente = @client || SimpliRoute::Client.new + cliente = self.cliente visitas = cliente.visitas_da_data(@data) return [] if visitas.empty? diff --git a/app/services/simpli_route/client.rb b/app/services/simpli_route/client.rb index 58494a1..f3d47f8 100644 --- a/app/services/simpli_route/client.rb +++ b/app/services/simpli_route/client.rb @@ -64,22 +64,64 @@ module SimpliRoute Array(get(caminho)) end - # Rotas PLANEJADAS da data (Array de hashes). É aqui que costuma morar o par - # veículo/motorista, que a visita talvez não carregue — e o romaneio precisa do - # veículo para saber quantas folhas imprimir. + # UMA rota pelo uuid. Serve para descobrir o DIA REAL de um plano: o registro + # do plano só traz a JANELA (`start_date`/`end_date`), e o dia das rotas nem + # sempre é o começo dela — medido em 28/08/2026: EMAD SETEMBRO 2026 (31/08 a + # 08/09) tem rotas em 31/08 (= início), mas UBS OESTE AGOSTO 2026 (18/08 a + # 20/08) e UBS NORTE AGOSTO 2026 (17/08 a 20/08) têm rotas em 20/08 (= FIM). + # Era isto que tornava a data impossível de adivinhar de fora; agora se + # pergunta à API. Todas as rotas de um plano dividem o mesmo dia (amostra de + # 8 em 3 planos), então basta a primeira. + def rota(id) + get("/v1/routes/routes/#{id}/") + rescue Error + nil + end + + # Rotas PLANEJADAS da data (Array de hashes). É aqui que mora o par + # veículo/motorista, e o romaneio precisa do veículo para saber quantas folhas + # imprimir. # - # ⚠️ NÃO VERIFICADO contra a API real: não há token na máquina de - # desenvolvimento. Por isso é best-effort (endpoint inexistente devolve [], não - # explode) e por isso Romaneios::PlanoDoDia tem uma escada de queda até o - # espelho local. Rode `bin/sondar_plano_do_dia` no servidor antes de confiar - # nesta chamada — e anote o resultado no cabeçalho daquele script, como foi - # feito em bin/sondar_busca_nf. + # ✔ VERIFICADO em 28/08/2026 contra a API real: `?planned_date=2026-08-31` + # devolve 73 rotas (83 KB, 0,59 s), cada uma com `vehicle` (id numérico), + # `plan` (uuid) e `total_visits`. ⚠️ O parâmetro é OBRIGATÓRIO: sem ele a rota + # responde 200 com **lista vazia**, não com tudo — então "veio vazio" aqui + # pode ser data errada, nunca "a conta não tem rotas". A rota NÃO traz array + # de visitas (`visits` não existe); quem liga visita→rota é o campo `route` + # da própria visita. def rotas_da_data(data) Array(get("/v1/routes/routes/?planned_date=#{data.to_date.iso8601}")) rescue Error [] end + # Os N planos mais recentes da conta — nome, período e as rotas de cada um. + # + # ⚠️ A CONCLUSÃO ANTIGA ("a API não expõe listagem de planos, só dá para + # buscar por data") ESTAVA ERRADA. `/v1/routes/plans/` existe, não é + # documentado, e devolve TUDO com o nome que a operação usa — "EMAD SETEMBRO + # 2026", "UBS OESTE AGOSTO 2026". Verificado em 28/08/2026 contra a API real: + # 138 planos, 185 KB, 0,86 s. Ver bin/sondar_planos. + # + # POR QUE ORDENAR E CORTAR AQUI, e não na URL: a API IGNORA todo filtro nesta + # rota. `ordering`, `limit`, `page`, `page_size`, `search`, `name`, + # `start_date__gte` — os dez candidatos testados devolveram os mesmos 138 + # itens do controle sem parâmetro. Então não existe "pedir 5": pede-se tudo + # (uma chamada, menos de 1 s) e corta-se em Ruby. + # + # `created` é o critério: `start_date` de um plano é o PRIMEIRO dia da janela + # (o EMAD SETEMBRO 2026 vai de 31/08 a 08/09), e é justamente essa janela que + # tornava impossível adivinhar "a data do plano". Quem publicou por último é + # quem o operador quer — ele abre a ferramenta logo depois de lançar. + def planos(limite: 5) + Array(get('/v1/routes/plans/')) + .sort_by { |p| p['created'].to_s } + .reverse + .first(limite) + rescue Error + [] + end + # Veículos da conta, para traduzir o id numérico que vem nas rotas # (`vehicle: 630011`) no nome que a operação usa (`GADE_038`). # diff --git a/app/views/admin/romaneios/index.html.erb b/app/views/admin/romaneios/index.html.erb index b2d0b60..0c8d7fc 100644 --- a/app/views/admin/romaneios/index.html.erb +++ b/app/views/admin/romaneios/index.html.erb @@ -70,10 +70,51 @@ authenticity_token: form_authenticity_token, data: { turbo: false }, class: 'space-y-4' do %>
+ <%# ── Plano ──────────────────────────────────────────────────────── + Antes aqui se pedia a DATA do plano, e era o passo que mais errava: + o operador pensa por operação ("lançou a UBS Sudeste"), e a data + nem estava ao alcance dele — o plano tem uma JANELA e o dia das + rotas ora é o começo dela, ora o fim (EMAD SETEMBRO 2026 vai de + 31/08 a 08/09 e roda em 31/08; UBS OESTE AGOSTO vai de 18 a 20/08 e + roda em 20/08). Agora ele escolhe pelo NOME e quem descobre o dia é + a API. + + Cinco planos porque a ferramenta é usada logo depois de lançar uma + operação — o que ele quer está sempre entre os últimos. + + A lista vazia (API fora, token vencido) NÃO tranca a tela: cai no + campo de data, que é o comportamento antigo inteiro. %>
- - Plano + + <% if @planos.present? %> + +

+ Os 5 planos mais recentes do SimpliRoute. O dia é descoberto pelo plano. +

+ <% else %> +

+ Não consegui listar os planos do SimpliRoute agora — informe a data. +

+ <% end %> + + <%# Continua existindo sempre: é o caminho do "— usar uma data + específica —" e a rede quando a API não responde. %> + + class="w-full mt-2 bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white focus:outline-none focus:border-orange-500 min-h-[48px]">
@@ -103,12 +144,18 @@ -

- Cole o nome do plano como está no SimpliRoute — é por ele que a - operação do mês (quem é NOVO) é encontrada. + <% if @planos.present? %> + Em branco, usa o nome do plano escolhido — é por ele que a operação + do mês (quem é NOVO) é encontrada. Preencha só para + imprimir um texto diferente no canto do PDF. + <% else %> + Cole o nome do plano como está no SimpliRoute — é por ele que a + operação do mês (quem é NOVO) é encontrada. + <% end %>

diff --git a/bin/sondar_planos b/bin/sondar_planos new file mode 100755 index 0000000..1db9123 --- /dev/null +++ b/bin/sondar_planos @@ -0,0 +1,329 @@ +#!/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