# app/services/romaneios/plano_do_dia.rb # # Busca o plano de UMA data e devolve as paradas na forma canônica do # Romaneios::Normalizador — a mesma que Romaneios::PlanoPlanilha entrega. # # ⚠️ NÃO está verificado se a visita de /v1/routes/visits/ carrega veículo/ordem, e # isso não é testável na máquina de desenvolvimento (sem token, sem .env). Por isso # aqui existe uma ESCADA DE QUEDA explícita, e não um caminho feliz único: # # a) rotas da API trazem vehicle + lista ordenada de visitas -> origem :api # b) as visitas trazem alguma chave de veículo/ordem -> origem :api # c) nada disso (ou sem token) -> ESPELHO local # d) espelho vazio na data -> NotFound, com a # mensagem que manda o operador usar o .xlsx do plano # # A rung (c) não é consolo: o romaneio é impresso ANTES do dia começar, e ela cobre # "plano publicado e já sincronizado, mas a API fora do ar" — a falha mais provável. # Rode bin/sondar_plano_do_dia no servidor para fechar (a)/(b). module Romaneios class PlanoDoDia # Chaves candidatas nos hashes da API. Só as usamos se vierem PREENCHIDAS — # a API devolve chave existente com valor nil o tempo todo. # # ⚠️ A ORDEM AQUI É O CONSERTO DE UM BUG REAL, não estilo. Na API o campo # `vehicle` da rota é o **ID numérico** do veículo (630011), e o nome que a # operação usa — o mesmo que a planilha do plano traz na coluna "Veículo" — # é GADE_038. Com `vehicle` na frente, o ID ganhava sempre e o romaneio saía # impresso "CONTROLE DE ENTREGA — 630011", com o motorista sem saber que # carro pegar. Nome primeiro; ID só como último recurso, porque veículo # vazio faz a linha ser descartada e o romaneio sair sem paradas. CHAVES_VEICULO = %w[vehicle_name vehicle_label vehicle vehicle_id].freeze # Dentro do objeto do veículo (quando a API aninha em vez de mandar o id). CHAVES_NOME_VEIC = %w[name label display_name plate].freeze CHAVES_MOTORISTA = %w[driver driver_name].freeze CHAVES_ORDEM = %w[order sequence position route_order].freeze attr_reader :origem # 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 def linhas @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 = @plano ? da_api_por_plano : da_api 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, 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 = self.cliente visitas = cliente.visitas_da_data(@data) return [] if visitas.empty? linhas = por_rotas(cliente, visitas) linhas = por_visitas(visitas, cliente) if linhas.empty? @origem = 'api' if linhas.any? linhas rescue SimpliRoute::Error # Token ausente/expirado ou API fora: cai para o espelho, que é a mesma # informação já sincronizada. [] end # (a) A rota traz o veículo e a lista de visitas na ordem do roteiro. def por_rotas(cliente, visitas) rotas = cliente.rotas_da_data(@data) return [] if rotas.empty? por_id = visitas.index_by { |v| v['id'] } linhas = [] rotas.each do |rota| veiculo = nome_do_veiculo(rota, cliente) next if veiculo.empty? ids = Array(rota['visits']).map { |v| v.is_a?(Hash) ? v['id'] : v } ids = visitas.select { |v| v['route'].to_s == rota['id'].to_s }.map { |v| v['id'] } if ids.empty? ids.each_with_index do |id, i| visita = por_id[id] next if visita.nil? linhas << monta(visita, veiculo, primeiro_valor(rota, CHAVES_MOTORISTA), i + 1) end end linhas end # (b) A visita traz o veículo direto; a ordem sai de `order`/`sequence` ou, # na falta, do horário estimado de chegada. def por_visitas(visitas, cliente) com_veiculo = visitas.reject { |v| nome_do_veiculo(v, cliente).empty? } return [] if com_veiculo.empty? com_veiculo.group_by { |v| nome_do_veiculo(v, cliente) }.flat_map do |veiculo, lista| ordenadas(lista).each_with_index.map do |visita, i| monta(visita, veiculo, primeiro_valor(visita, CHAVES_MOTORISTA), i + 1) end end end def ordenadas(lista) lista.sort_by.with_index do |v, i| chave = CHAVES_ORDEM.filter_map { |k| v[k] }.first [chave.nil? ? Float::INFINITY : chave.to_i, v['estimated_time_arrival'].to_s, i] end end def monta(visita, veiculo, motorista, ordem) Normalizador.linha( veiculo: veiculo, ordem: ordem, motorista: motorista, titulo: visita['title'], endereco: visita['address'], anotacoes: visita['notes'], nota_fiscal: visita['reference'], tracking_id: visita['tracking_id'] ) end # O NOME do veículo como a operação o conhece (GADE_038), nunca o id. # # Três formas já vistas/possíveis no mesmo campo, por isso as três camadas: # { "vehicle_name" => "GADE_038" } -> nome direto # { "vehicle" => { "name" => "GADE_038" } } -> aninhado # { "vehicle" => 630011 } -> só o id, precisa resolver # # Quando sobra só o id, procuramos o nome na lista de veículos da conta. Se # essa busca não responder, devolvemos o id mesmo: um romaneio com o número # do carro é ruim, um romaneio VAZIO (que é o que acontece devolvendo "") é # pior — a linha sem veículo é descartada. def nome_do_veiculo(hash, cliente) aninhado = hash['vehicle'] return primeiro_valor(aninhado, CHAVES_NOME_VEIC) if aninhado.is_a?(Hash) valor = primeiro_valor(hash, CHAVES_VEICULO) return valor unless valor.match?(/\A\d+\z/) nomes_de_veiculo(cliente)[valor].presence || valor end # id (string) => nome. Uma chamada por importação, memoizada: são ~70 veículos # e a alternativa seria uma consulta por rota. def nomes_de_veiculo(cliente) @nomes_de_veiculo ||= cliente.veiculos.each_with_object({}) do |v, memo| next unless v.is_a?(Hash) id = v['id'].to_s.strip nome = primeiro_valor(v, CHAVES_NOME_VEIC) memo[id] = nome unless id.empty? || nome.empty? end end def primeiro_valor(hash, chaves) chaves.filter_map { |k| hash[k] }.map(&:to_s).find { |v| !v.strip.empty? }.to_s.strip end # ── (c) Espelho local ──────────────────────────────────────────────────── # `db_reem_simplerout_2026` já tem driver, vehicle, title, address, notes e eta. # Só leitura, como sempre. def do_espelho registros = Entrega.da_conta_gade .no_periodo(@data, @data) .where.not(vehicle: [nil, '']) .order(:vehicle, :eta) .to_a return [] if registros.empty? @origem = 'espelho' registros.group_by(&:vehicle).flat_map do |veiculo, lista| lista.each_with_index.map do |e, i| Normalizador.linha( veiculo: veiculo, ordem: i + 1, motorista: e.driver, titulo: e.title, endereco: e.address, anotacoes: e.notes, nota_fiscal: e.reference_id, tracking_id: e.tracking_id ) end end end end end