387 lines
15 KiB
Ruby
387 lines
15 KiB
Ruby
# app/controllers/admin/edicao_lancamentos_controller.rb
|
|
#
|
|
# Correção de lançamentos do SimpliRoute pelo ADM. Busca a entrega pela NF (na
|
|
# base de rastreio local), resolve o id numérico na API e grava a alteração
|
|
# DIRETO na API do SimpliRoute — registrando tudo no AuditoriaLog.
|
|
#
|
|
# ⚠️ A base de rastreio local (Entrega) é só leitura e sincroniza depois: a
|
|
# alteração só reflete no painel na próxima sincronização.
|
|
class Admin::EdicaoLancamentosController < ApplicationController
|
|
before_action :garantir_configurado
|
|
|
|
# Campos que o ADM pode editar (fase 1). checkout_observation = UUID do motivo.
|
|
CAMPOS_EDITAVEIS = %w[
|
|
status checkout_observation checkout_comment notes
|
|
checkout_time checkout_latitude checkout_longitude
|
|
].freeze
|
|
|
|
STATUS_VALIDOS = %w[pending partial completed failed canceled].freeze
|
|
|
|
def show
|
|
authorize :edicao_lancamento
|
|
@motivos = motivos_seguros
|
|
end
|
|
|
|
# GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com TODOS os lançamentos
|
|
# daquela NF.
|
|
#
|
|
# ⚠️ Uma NF pode ter mais de um lançamento: quando o plano é duplicado no
|
|
# SimpliRoute nasce uma visita nova (outro tracking_id) com a mesma NF, e a
|
|
# antiga — a que tem o motorista/checkout — continua existindo. Por isso a
|
|
# busca devolve a LISTA e o ADM escolhe qual editar; assumir "1 NF = 1
|
|
# lançamento" escondia justamente o lançamento antigo.
|
|
#
|
|
# Com um único lançamento já vem o card montado, para não custar um segundo
|
|
# round-trip no caso comum.
|
|
def buscar
|
|
authorize :edicao_lancamento
|
|
|
|
nf = params[:nf].to_s.strip
|
|
return render_erro('Informe o número da NF.') if nf.blank?
|
|
|
|
entregas = Entrega.por_nf(nf).to_a
|
|
return render_erro("NF #{nf} não encontrada na base de rastreio.") if entregas.empty?
|
|
|
|
ocorrencias = ocorrencias_da_nf(nf, entregas)
|
|
unica = ocorrencias.first if ocorrencias.one? && ocorrencias.first[:id].present?
|
|
|
|
render json: {
|
|
ok: true,
|
|
ocorrencias: ocorrencias,
|
|
visita: (unica && payload_visita(unica[:id], entrega_de(entregas, unica[:tracking_id]))),
|
|
motivos: motivos_seguros
|
|
}
|
|
rescue SimpliRoute::NotFound => e
|
|
render_erro(e.message)
|
|
rescue SimpliRoute::Error => e
|
|
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
|
|
end
|
|
|
|
# GET /admin/edicao_lancamento/carregar?visit_id=871407488&tracking_id=SR...
|
|
# Monta o card de UM lançamento escolhido na lista de ocorrências.
|
|
def carregar
|
|
authorize :edicao_lancamento
|
|
|
|
id = params[:visit_id].to_s.strip
|
|
return render_erro('Lançamento não identificado.') if id.blank?
|
|
|
|
entrega = Entrega.find_by(tracking_id: params[:tracking_id]) if params[:tracking_id].present?
|
|
|
|
render json: { ok: true, visita: payload_visita(id, entrega), motivos: motivos_seguros }
|
|
rescue SimpliRoute::NotFound => e
|
|
render_erro(e.message)
|
|
rescue SimpliRoute::Error => e
|
|
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
|
|
end
|
|
|
|
# PATCH /admin/edicao_lancamento/atualizar
|
|
# HTML: fluxo de form clássico (fallback). JSON: salvar-por-campo do card
|
|
# (click-to-edit) — o fetch manda visit_id + só o campo editado.
|
|
def atualizar
|
|
authorize :edicao_lancamento
|
|
|
|
id = params[:visit_id].to_s.strip
|
|
return responder_erro('Visita não identificada.') if id.blank?
|
|
|
|
anterior = client.visita(id)
|
|
attrs = mudancas(anterior)
|
|
|
|
# Confirmar um valor idêntico não é erro no inline edit — só não há o que fazer.
|
|
if attrs.empty?
|
|
return respond_to do |format|
|
|
format.json { render json: { ok: true, campos: [], visita: {} } }
|
|
format.html { redirect_to admin_edicao_lancamento_path, alert: 'Nenhuma alteração informada.' }
|
|
end
|
|
end
|
|
if attrs['status'].present? && STATUS_VALIDOS.exclude?(attrs['status'])
|
|
return responder_erro('Status inválido.')
|
|
end
|
|
|
|
atualizada = client.atualizar_visita(id, attrs)
|
|
|
|
AuditoriaLog.registrar(
|
|
user: current_user,
|
|
acao: 'editar',
|
|
entidade: 'SimpliRoute::Visita',
|
|
entidade_id: id,
|
|
dados_anteriores: anterior.slice(*attrs.keys),
|
|
dados_novos: atualizada.slice(*attrs.keys),
|
|
request: request
|
|
)
|
|
|
|
respond_to do |format|
|
|
format.json do
|
|
render json: { ok: true, campos: attrs.keys, visita: atualizada.slice(*CAMPOS_EDITAVEIS) }
|
|
end
|
|
format.html do
|
|
redirect_to admin_edicao_lancamento_path,
|
|
notice: "Lançamento da NF #{atualizada['reference']} atualizado no SimpliRoute. " \
|
|
'O painel refletirá na próxima sincronização.'
|
|
end
|
|
end
|
|
rescue SimpliRoute::Error => e
|
|
responder_erro("Não foi possível atualizar: #{e.message}", status: :bad_gateway)
|
|
end
|
|
|
|
# GET /admin/edicao_lancamento/historico?visit_id=123 → JSON com as DUAS fontes:
|
|
# o histórico da API do SimpliRoute (best-effort, costuma vir vazio) e a nossa
|
|
# trilha do AuditoriaLog (quem editou, quando, de→para).
|
|
def historico
|
|
authorize :edicao_lancamento
|
|
|
|
id = params[:visit_id].to_s.strip
|
|
interno = AuditoriaLog.por_entidade('SimpliRoute::Visita')
|
|
.where(entidade_id: id)
|
|
.includes(:user).recentes.limit(50)
|
|
.map do |log|
|
|
{
|
|
usuario: log.user&.nome_display || '—',
|
|
acao: log.acao,
|
|
quando: log.created_at.in_time_zone.strftime('%d/%m/%Y %H:%M'),
|
|
de: log.dados_anteriores,
|
|
para: log.dados_novos
|
|
}
|
|
end
|
|
|
|
render json: { ok: true, api: client.historico(id), interno: interno }
|
|
end
|
|
|
|
private
|
|
|
|
def client
|
|
@client ||= SimpliRoute::Client.new
|
|
end
|
|
|
|
# Todos os lançamentos da NF, cruzando as DUAS fontes:
|
|
# • espelho de rastreio (Entrega) — traz motorista/veículo;
|
|
# • API do SimpliRoute, nas datas desses lançamentos — pega a visita
|
|
# duplicada que a sincronização ainda não trouxe para o espelho.
|
|
#
|
|
# A API não tem busca global por NF (só por data), então as datas vêm do
|
|
# espelho: 1 chamada por data distinta (plano duplicado costuma cair no mesmo
|
|
# dia, e aí é 1 chamada só).
|
|
def ocorrencias_da_nf(nf, entregas)
|
|
datas = entregas.filter_map { |e| e.planned_date&.to_date }.uniq
|
|
trackings = entregas.map { |e| e.tracking_id.to_s }
|
|
do_dia = datas.flat_map { |data| client.visitas_da_data(data) }
|
|
|
|
visitas = do_dia.select { |v| v['reference'].to_s == nf || trackings.include?(v['tracking_id'].to_s) }
|
|
.uniq { |v| v['id'] }
|
|
|
|
achadas = visitas.map { |v| ocorrencia_da_api(v, entrega_de(entregas, v['tracking_id'])) }
|
|
|
|
# Lançamento que existe no espelho mas não apareceu na API (data sem plano,
|
|
# visita removida): entra na lista como não editável, para o ADM ver que
|
|
# ele existe em vez de sumir silenciosamente — que é o bug que estamos
|
|
# corrigindo.
|
|
vistos = visitas.map { |v| v['tracking_id'].to_s }
|
|
orfas = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) }.map { |e| ocorrencia_do_espelho(e) }
|
|
|
|
# Mais recente primeiro (a data vem como texto ISO da API e como timestamp
|
|
# do espelho — os 10 primeiros caracteres normalizam as duas).
|
|
(achadas + orfas).sort_by { |o| [o[:data].to_s[0, 10], o[:id].to_i] }.reverse
|
|
end
|
|
|
|
def entrega_de(entregas, tracking_id)
|
|
entregas.find { |e| e.tracking_id.to_s == tracking_id.to_s }
|
|
end
|
|
|
|
# Resumo de um lançamento para a lista de escolha (não carrega fotos/detalhe —
|
|
# isso só acontece quando o ADM abre um).
|
|
def ocorrencia_da_api(visita, entrega)
|
|
{
|
|
id: visita['id'],
|
|
tracking_id: visita['tracking_id'],
|
|
titulo: visita['title'],
|
|
endereco: visita['address'],
|
|
status: visita['status'],
|
|
data: visita['planned_date'],
|
|
checkout_time: visita['checkout_time'],
|
|
motorista: entrega&.driver,
|
|
veiculo: entrega&.vehicle,
|
|
editavel: true,
|
|
# Está na API mas ainda não no espelho — daí não sabermos motorista/veículo.
|
|
aviso: entrega ? nil : 'Ainda não sincronizado no painel'
|
|
}
|
|
end
|
|
|
|
def ocorrencia_do_espelho(entrega)
|
|
{
|
|
id: nil,
|
|
tracking_id: entrega.tracking_id,
|
|
titulo: entrega.local,
|
|
endereco: entrega.address,
|
|
status: entrega.status,
|
|
data: entrega.planned_date,
|
|
checkout_time: entrega.checkout,
|
|
motorista: entrega.driver,
|
|
veiculo: entrega.vehicle,
|
|
editavel: false,
|
|
aviso: 'Não localizado na API do SimpliRoute — não é editável'
|
|
}
|
|
end
|
|
|
|
# Estado completo de UMA visita para o card (inclui a galeria de fotos, que
|
|
# custa uma chamada extra ao POD). `entrega` pode ser nil quando a visita
|
|
# ainda não sincronizou no espelho.
|
|
def payload_visita(id, entrega)
|
|
visita = client.visita(id)
|
|
# Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}.
|
|
detalhe = client.detalhe_visita(id)
|
|
|
|
{
|
|
id: id,
|
|
tracking_id: visita['tracking_id'],
|
|
nf: visita['reference'],
|
|
titulo: visita['title'],
|
|
endereco: visita['address'],
|
|
status: visita['status'],
|
|
checkout_observation: visita['checkout_observation'],
|
|
checkout_comment: visita['checkout_comment'],
|
|
notes: visita['notes'],
|
|
checkout_time: visita['checkout_time'],
|
|
checkout_latitude: visita['checkout_latitude'],
|
|
checkout_longitude: visita['checkout_longitude'],
|
|
planned_date: visita['planned_date'],
|
|
contato: visita['contact_name'],
|
|
telefone: visita['contact_phone'],
|
|
# Galeria unificada — TODAS as fotos do lançamento, de todas as fontes,
|
|
# cada uma etiquetada para o ADM saber o que está conferindo.
|
|
fotos: fotos_do_lancamento(entrega, visita, detalhe),
|
|
motorista: entrega&.driver,
|
|
veiculo: entrega&.vehicle
|
|
}
|
|
end
|
|
|
|
# Monta o hash de PATCH só com os campos que vieram no form E que realmente
|
|
# mudaram em relação ao estado atual — PATCH mínimo, sem sobrescrever à toa.
|
|
def mudancas(anterior)
|
|
CAMPOS_EDITAVEIS.each_with_object({}) do |campo, memo|
|
|
next unless params.key?(campo)
|
|
|
|
novo = normalizar(campo, params[campo])
|
|
next if equivalente?(campo, novo, anterior[campo])
|
|
|
|
memo[campo] = novo
|
|
end
|
|
end
|
|
|
|
# Evita PATCH falso: nil/"" são equivalentes; datas comparadas por instante;
|
|
# lat/long por valor numérico (o reparse difere do texto original da API).
|
|
def equivalente?(campo, novo, atual)
|
|
return true if novo.to_s == atual.to_s
|
|
|
|
case campo
|
|
when 'checkout_time'
|
|
a = (Time.zone.parse(novo.to_s) rescue nil)
|
|
b = (Time.zone.parse(atual.to_s) rescue nil)
|
|
a.present? && b.present? && a.to_i == b.to_i
|
|
when 'checkout_latitude', 'checkout_longitude'
|
|
novo.is_a?(Float) && atual.present? && (novo - atual.to_f).abs < 1e-9
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
# Normaliza valores por campo. Campos em branco viram nil (permite LIMPAR o
|
|
# motivo/comentário). checkout_time aceita datetime-local e vira ISO8601.
|
|
def normalizar(campo, valor)
|
|
valor = valor.to_s.strip
|
|
return nil if valor.blank?
|
|
|
|
case campo
|
|
when 'checkout_latitude', 'checkout_longitude'
|
|
Float(valor) rescue valor
|
|
when 'checkout_time'
|
|
(Time.zone.parse(valor)&.iso8601 rescue nil) || valor
|
|
else
|
|
valor
|
|
end
|
|
end
|
|
|
|
# Lista de motivos; nunca quebra a tela se a API estiver fora.
|
|
def motivos_seguros
|
|
client.observations
|
|
rescue SimpliRoute::Error
|
|
[]
|
|
end
|
|
|
|
def render_erro(msg, status: :unprocessable_entity)
|
|
render json: { ok: false, erro: msg }, status: status
|
|
end
|
|
|
|
# Erro do atualizar nos dois formatos (JSON p/ inline edit, HTML p/ fallback).
|
|
def responder_erro(msg, status: :unprocessable_entity)
|
|
respond_to do |format|
|
|
format.json { render json: { ok: false, erro: msg }, status: status }
|
|
format.html { redirect_to admin_edicao_lancamento_path, alert: msg }
|
|
end
|
|
end
|
|
|
|
# Só aceita URL http(s) — evita injetar lixo no <img src> do card (mesmo
|
|
# filtro do foto_url do OperacaoMetricas).
|
|
def url_imagem(valor)
|
|
url = valor.to_s.strip
|
|
url.match?(%r{\Ahttps?://}i) ? url : nil
|
|
end
|
|
|
|
# Campos de foto customizados do SimpliRoute (extra_field_values). É AQUI que
|
|
# ficam as fotos reais tiradas pelo motorista — o array `pictures` só traz uma
|
|
# imagem genérica. Mapa: campo da API => [rótulo p/ o ADM, categoria de cor].
|
|
# Campos fora deste mapa ainda aparecem (rótulo derivado do nome do campo).
|
|
FOTOS_EXTRA = {
|
|
'foto_prova_visita' => ['Prova da visita', 'prova'],
|
|
'foto_nf' => ['Nota Fiscal', 'nf'],
|
|
'foto_termo' => ['Termo', 'termo'],
|
|
'foto_relatorio2' => ['Relatório', 'relatorio']
|
|
}.freeze
|
|
|
|
# Reúne TODAS as imagens do lançamento numa lista única e etiquetada:
|
|
# 1. espelho de rastreio -> foto da fachada (mesma do mapa de operações)
|
|
# 2. extra_field_values -> fotos reais do motorista (foto_nf, prova, etc.)
|
|
# 3. pictures[]/signature -> imagens genéricas + assinatura
|
|
#
|
|
# A mesma foto pode vir de mais de uma fonte, então deduplica por URL mantendo
|
|
# a primeira etiqueta (a ordem abaixo é a mais informativa).
|
|
def fotos_do_lancamento(entrega, visita, detalhe)
|
|
brutas = []
|
|
brutas << [entrega.try(:foto_da_fachada), 'Fachada', 'fachada', 'Rastreio']
|
|
|
|
# Fotos customizadas — o /detail/ é a fonte mais completa; cai p/ a visita.
|
|
extras = detalhe['extra_field_values'].presence || visita['extra_field_values'] || {}
|
|
extras.each do |campo, valor|
|
|
rotulo, cat = FOTOS_EXTRA[campo] || [rotulo_do_campo(campo), 'extra']
|
|
brutas << [valor, rotulo, cat, 'SimpliRoute']
|
|
end
|
|
|
|
[[visita, 'Visita'], [detalhe, 'Comprovante']].each do |fonte, origem|
|
|
Array(fonte['pictures']).each { |u| brutas << [u, 'Comprovante', 'entrega', origem] }
|
|
brutas << [fonte['signature'], 'Assinatura', 'assinatura', origem]
|
|
end
|
|
|
|
vistas = Set.new
|
|
brutas.filter_map do |valor, rotulo, cat, origem|
|
|
url = url_imagem(valor)
|
|
next if url.nil? || !vistas.add?(url)
|
|
|
|
{ url: url, rotulo: rotulo, cat: cat, origem: origem }
|
|
end
|
|
end
|
|
|
|
# "foto_relatorio2" -> "Relatorio2". Fallback p/ campos de foto não mapeados.
|
|
def rotulo_do_campo(campo)
|
|
campo.to_s.sub(/\Afoto_?/, '').tr('_', ' ').strip.capitalize.presence || campo.to_s
|
|
end
|
|
|
|
# Sem token não há o que fazer — avisa e volta.
|
|
def garantir_configurado
|
|
return if SimpliRoute.configurado?
|
|
|
|
msg = 'Integração com o SimpliRoute não configurada (defina SIMPLIROUTE_TOKEN no servidor).'
|
|
respond_to do |format|
|
|
format.html { redirect_to dashboard_path, alert: msg }
|
|
format.json { render json: { ok: false, erro: msg }, status: :service_unavailable }
|
|
end
|
|
end
|
|
end
|