277 lines
11 KiB
Ruby
277 lines
11 KiB
Ruby
# 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
|