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 %>
+ 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]">- 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 %>