teste #8

Merged
victor merged 4 commits from teste into main 2026-07-20 22:26:52 -03:00
4 changed files with 319 additions and 38 deletions
Showing only changes of commit f6eda043a3 - Show all commits

View File

@@ -34,6 +34,8 @@ class Admin::EdicaoLancamentosController < ApplicationController
id = client.resolver_id(entrega) id = client.resolver_id(entrega)
visita = client.visita(id) visita = client.visita(id)
# Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}.
detalhe = client.detalhe_visita(id)
render json: { render json: {
ok: true, ok: true,
@@ -52,11 +54,9 @@ class Admin::EdicaoLancamentosController < ApplicationController
planned_date: visita['planned_date'], planned_date: visita['planned_date'],
contato: visita['contact_name'], contato: visita['contact_name'],
telefone: visita['contact_phone'], telefone: visita['contact_phone'],
# Fotos do card: fachada vem do rastreio (mesma foto do mapa de operações); # Galeria unificada — TODAS as fotos do lançamento, de todas as fontes,
# pictures/assinatura vêm da API. Só URLs http(s) chegam ao <img>. # cada uma etiquetada para o ADM saber o que está conferindo.
foto_fachada: url_imagem(entrega.try(:foto_da_fachada)), fotos: fotos_do_lancamento(entrega, visita, detalhe),
pictures: Array(visita['pictures']).filter_map { |u| url_imagem(u) },
signature: url_imagem(visita['signature']),
motorista: entrega.driver, motorista: entrega.driver,
veiculo: entrega.vehicle veiculo: entrega.vehicle
}, },
@@ -218,6 +218,31 @@ class Admin::EdicaoLancamentosController < ApplicationController
url.match?(%r{\Ahttps?://}i) ? url : nil url.match?(%r{\Ahttps?://}i) ? url : nil
end end
# Reúne as imagens das TRÊS fontes numa lista única e etiquetada:
# 1. espelho de rastreio -> foto da fachada (mesma do mapa de operações)
# 2. visita da API -> pictures[] + signature
# 3. detalhe do POD -> pictures[] + signature (costuma ser mais completo)
#
# A mesma foto costuma aparecer em 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', 'Rastreio']
[[visita, 'Visita'], [detalhe, 'Comprovante']].each do |fonte, origem|
Array(fonte['pictures']).each { |u| brutas << [u, 'entrega', origem] }
brutas << [fonte['signature'], 'assinatura', origem]
end
vistas = Set.new
brutas.filter_map do |valor, tipo, origem|
url = url_imagem(valor)
next if url.nil? || !vistas.add?(url)
{ url: url, tipo: tipo, origem: origem }
end
end
# Sem token não há o que fazer — avisa e volta. # Sem token não há o que fazer — avisa e volta.
def garantir_configurado def garantir_configurado
return if SimpliRoute.configurado? return if SimpliRoute.configurado?

View File

@@ -84,6 +84,17 @@ module SimpliRoute
[] []
end end
# Detalhe do comprovante de entrega (POD): traz foto, assinatura, hora e GPS.
# É uma fonte MAIS COMPLETA de imagens que `visita(id)` — a visita crua às
# vezes vem com `pictures` vazio mesmo havendo foto registrada no checkout.
# Best-effort: se o endpoint não existir/responder, devolve {} e a tela segue
# com o que a visita tiver.
def detalhe_visita(id)
get("/v1/plans/visits/#{id}/detail/") || {}
rescue Error
{}
end
private private
def get(path) def get(path)

View File

@@ -71,14 +71,12 @@
<div class="grid lg:grid-cols-3 gap-0"> <div class="grid lg:grid-cols-3 gap-0">
<%# Coluna esquerda — fotos %> <%# Coluna esquerda — galeria com TODAS as fotos do lançamento %>
<div class="p-5 border-b lg:border-b-0 lg:border-r border-white/5 space-y-3"> <div class="p-5 border-b lg:border-b-0 lg:border-r border-white/5 space-y-3">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Foto da fachada</p> <p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">
<div id="c-foto-wrap"></div> Fotos do lançamento <span id="c-fotos-contador" class="normal-case font-normal text-gray-600"></span>
<div id="c-extras-wrap" class="hidden"> </p>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mt-4 mb-2">Fotos do SimpliRoute</p> <div id="c-fotos" class="space-y-3"></div>
<div id="c-extras" class="grid grid-cols-3 gap-2"></div>
</div>
</div> </div>
<%# Coluna direita — campos click-to-edit %> <%# Coluna direita — campos click-to-edit %>
@@ -105,6 +103,38 @@
</div> </div>
</div> </div>
</div> </div>
<%# Visualizador de foto — amplia e navega entre todas as imagens do lançamento %>
<div id="modal-foto" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
<div id="modal-foto-overlay" class="absolute inset-0 bg-black/85"></div>
<div class="relative w-full max-w-3xl flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<p id="modal-foto-titulo" class="text-white font-semibold text-sm"></p>
<div class="flex items-center gap-2">
<a id="modal-foto-abrir" href="#" target="_blank" rel="noopener"
class="px-3 py-2 rounded-lg bg-white/5 hover:bg-white/10 text-gray-300 text-xs inline-flex items-center">
<%= icone :visualizar, cor: nil, tamanho: 'w-4 h-4' %> Tamanho real
</a>
<button id="modal-foto-fechar" type="button"
class="w-9 h-9 rounded-lg bg-white/5 hover:bg-white/10 text-gray-300 flex items-center justify-center"
aria-label="Fechar"><%= icone :fechar, cor: nil, tamanho: 'w-5 h-5', espaco: false %></button>
</div>
</div>
<div class="relative bg-[#151515] border border-white/10 rounded-2xl overflow-hidden">
<img id="modal-foto-img" src="" alt="" class="w-full max-h-[70vh] object-contain bg-black">
<button id="modal-foto-ant" type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/70 hover:bg-black
text-white flex items-center justify-center" aria-label="Foto anterior">
<%= icone :voltar, cor: nil, tamanho: 'w-5 h-5', espaco: false %>
</button>
<button id="modal-foto-prox" type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/70 hover:bg-black
text-white flex items-center justify-center" aria-label="Próxima foto">
<%= icone :avancar, cor: nil, tamanho: 'w-5 h-5', espaco: false %>
</button>
</div>
</div>
</div>
</div> </div>
<script> <script>
@@ -202,34 +232,89 @@
b.innerHTML = ico(cfg.icone) + ' ' + esc(cfg.label); b.innerHTML = ico(cfg.icone) + ' ' + esc(cfg.label);
} }
// Etiqueta de cada tipo de imagem — o ADM precisa saber o que está olhando
// para julgar se a foto está errada.
// `cls` = etiqueta translúcida (foto em destaque, fundo escuro previsível).
// `txt` = só a cor do texto, usada na miniatura sobre fundo preto sólido —
// sobre foto clara a versão translúcida ficava ilegível.
const ROTULO_FOTO = {
fachada: { texto: 'Fachada', cls: 'bg-orange-500/20 text-orange-300 border-orange-500/40', txt: 'text-orange-300' },
entrega: { texto: 'Comprovante', cls: 'bg-blue-500/20 text-blue-300 border-blue-500/40', txt: 'text-blue-300' },
assinatura: { texto: 'Assinatura', cls: 'bg-purple-500/20 text-purple-300 border-purple-500/40', txt: 'text-purple-300' }
};
function renderFotos() { function renderFotos() {
const wrap = document.getElementById('c-foto-wrap'); const fotos = visita.fotos || [];
if (visita.foto_fachada) { const wrap = document.getElementById('c-fotos');
wrap.innerHTML = const cont = document.getElementById('c-fotos-contador');
'<a href="' + esc(visita.foto_fachada) + '" target="_blank" rel="noopener" title="Abrir em tamanho real">' +
'<img src="' + esc(visita.foto_fachada) + '" alt="Fachada" loading="lazy" ' + cont.textContent = fotos.length ? '— ' + fotos.length + (fotos.length > 1 ? ' imagens' : ' imagem') : '';
'class="w-full h-52 object-cover rounded-xl border border-white/10 hover:opacity-90 transition-opacity">' +
'</a>'; if (!fotos.length) {
} else {
wrap.innerHTML = wrap.innerHTML =
'<div class="w-full h-52 rounded-xl border border-dashed border-white/10 flex items-center justify-center ' + '<div class="w-full h-52 rounded-xl border border-dashed border-white/10 flex items-center justify-center ' +
'text-gray-600 text-sm">' + ico('camera-fill') + ' Sem foto da fachada para esta entrega</div>'; 'text-gray-600 text-sm">' + ico('camera-fill') + ' Nenhuma foto registrada neste lançamento</div>';
return;
} }
const extras = [...(visita.pictures || [])]; // A 1ª em destaque; as demais numa grade de 3. Clicar abre o visualizador.
if (visita.signature) extras.push(visita.signature); // Na miniatura a etiqueta é compacta e truncada (senão estoura a célula);
const extrasWrap = document.getElementById('c-extras-wrap'); // o rótulo completo + origem aparecem no visualizador.
const grid = document.getElementById('c-extras'); const figura = (f, i, destaque) => {
if (extras.length) { const r = ROTULO_FOTO[f.tipo] ||
grid.innerHTML = extras.map(u => { texto: f.tipo, cls: 'bg-white/10 text-gray-300 border-white/20', txt: 'text-gray-300' };
'<a href="' + esc(u) + '" target="_blank" rel="noopener">' + const etiqueta = destaque
'<img src="' + esc(u) + '" loading="lazy" class="w-full h-16 object-cover rounded-lg border border-white/10 hover:opacity-90">' + ? '<figcaption class="absolute top-1.5 left-1.5 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ' +
'</a>').join(''); 'tracking-wide border ' + r.cls + '">' + esc(r.texto) + '</figcaption>' +
extrasWrap.classList.remove('hidden'); '<span class="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded bg-black/70 text-gray-300 text-[10px]">' +
} else { esc(f.origem) + '</span>'
extrasWrap.classList.add('hidden'); : '<figcaption class="absolute top-1 left-1 right-1 px-1 py-0.5 rounded text-[9px] font-bold uppercase ' +
grid.innerHTML = ''; 'tracking-tight truncate bg-black/80 ' + r.txt + '">' + esc(r.texto) + '</figcaption>';
return '<figure class="relative group cursor-zoom-in" data-foto-idx="' + i + '" ' +
'title="' + esc(r.texto + ' · ' + f.origem) + ' — clique para ampliar">' +
'<img src="' + esc(f.url) + '" alt="' + esc(r.texto) + '" loading="lazy" ' +
'class="w-full ' + (destaque ? 'h-52' : 'h-24') + ' object-cover rounded-xl border border-white/10 ' +
'group-hover:opacity-90 transition-opacity">' +
etiqueta +
'</figure>';
};
const resto = fotos.slice(1).map((f, i) => figura(f, i + 1, false)).join('');
wrap.innerHTML = figura(fotos[0], 0, true) +
(resto ? '<div class="grid grid-cols-3 gap-2">' + resto + '</div>' : '');
wrap.querySelectorAll('[data-foto-idx]').forEach(el =>
el.addEventListener('click', () => abrirVisualizador(+el.dataset.fotoIdx)));
} }
// ── Visualizador (lightbox) — amplia e navega entre as fotos ──────────
let fotoAtual = 0;
function abrirVisualizador(i) {
fotoAtual = i;
document.getElementById('modal-foto').classList.remove('hidden');
pintarVisualizador();
}
function fecharVisualizador() {
document.getElementById('modal-foto').classList.add('hidden');
}
function navegarFoto(passo) {
const fotos = visita.fotos || [];
if (!fotos.length) return;
fotoAtual = (fotoAtual + passo + fotos.length) % fotos.length;
pintarVisualizador();
}
function pintarVisualizador() {
const fotos = visita.fotos || [];
const f = fotos[fotoAtual];
if (!f) return;
const r = ROTULO_FOTO[f.tipo] || { texto: f.tipo };
document.getElementById('modal-foto-img').src = f.url;
document.getElementById('modal-foto-titulo').textContent =
r.texto + ' · ' + f.origem + ' (' + (fotoAtual + 1) + '/' + fotos.length + ')';
document.getElementById('modal-foto-abrir').href = f.url;
} }
// ── Campos click-to-edit ──────────────────────────────────── // ── Campos click-to-edit ────────────────────────────────────
@@ -365,7 +450,20 @@
function fecharModal() { modal.classList.add('hidden'); } function fecharModal() { modal.classList.add('hidden'); }
document.getElementById('modal-hist-fechar').onclick = fecharModal; document.getElementById('modal-hist-fechar').onclick = fecharModal;
document.getElementById('modal-hist-overlay').onclick = fecharModal; document.getElementById('modal-hist-overlay').onclick = fecharModal;
document.addEventListener('keydown', e => { if (e.key === 'Escape') fecharModal(); });
// ── Visualizador de foto ────────────────────────────────────
document.getElementById('modal-foto-fechar').onclick = fecharVisualizador;
document.getElementById('modal-foto-overlay').onclick = fecharVisualizador;
document.getElementById('modal-foto-ant').onclick = () => navegarFoto(-1);
document.getElementById('modal-foto-prox').onclick = () => navegarFoto(1);
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { fecharModal(); fecharVisualizador(); }
// Setas só navegam com o visualizador aberto, senão atrapalham os campos.
if (document.getElementById('modal-foto').classList.contains('hidden')) return;
if (e.key === 'ArrowLeft') navegarFoto(-1);
if (e.key === 'ArrowRight') navegarFoto(1);
});
document.getElementById('btn-historico').addEventListener('click', async () => { document.getElementById('btn-historico').addEventListener('click', async () => {
if (!visita) return; if (!visita) return;

147
bin/sondar_fotos_simpliroute Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Sonda a API do SimpliRoute para descobrir se dá para ALTERAR as fotos de uma
# visita. A documentação oficial não diz — este script responde na prática.
#
# Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN):
#
# # 1) Leitura — não muda nada. Comece sempre por aqui.
# SIMPLIROUTE_TOKEN=xxx bin/sondar_fotos_simpliroute --visita 871407488
#
# # 2) Teste de escrita — adiciona uma foto de teste e RESTAURA em seguida.
# SIMPLIROUTE_TOKEN=xxx bin/sondar_fotos_simpliroute --visita 871407488 --testar-escrita
#
# Para achar o id numérico de uma visita: é o mesmo que a tela "Editar
# Lançamento" mostra em "Visita <id>" depois de buscar pela NF.
#
# ⚠️ O teste de escrita mexe numa visita REAL. Use uma visita de teste ou uma
# entrega antiga/sem importância. O valor original é impresso antes de qualquer
# alteração e a restauração roda em `ensure` — se algo falhar, dá para refazer
# na mão com o JSON impresso.
require 'net/http'
require 'json'
require 'uri'
BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com')
TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s
# URL pública qualquer, só para ver se a API aceita o campo. Não é baixada por nós.
URL_TESTE = 'https://via.placeholder.com/64.png#sondagem-reem'
def sair(msg)
warn msg
exit 1
end
sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty?
visita_id = nil
testar_escrita = false
ARGV.each_with_index do |a, i|
visita_id = ARGV[i + 1] if a == '--visita'
testar_escrita = true if a == '--testar-escrita'
end
sair('Uso: bin/sondar_fotos_simpliroute --visita <id> [--testar-escrita]') if visita_id.to_s.empty?
def requisicao(metodo, caminho, corpo = nil)
uri = URI.join(BASE, caminho)
req = case metodo
when :get then Net::HTTP::Get.new(uri)
when :patch then Net::HTTP::Patch.new(uri)
end
req['Authorization'] = "Token #{TOKEN}"
req['Content-Type'] = 'application/json'
req['Accept'] = 'application/json'
req.body = corpo.to_json if corpo
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
open_timeout: 10, read_timeout: 30) { |h| h.request(req) }
body = begin
res.body.to_s.empty? ? nil : JSON.parse(res.body)
rescue JSON::ParserError
res.body
end
[res.code.to_i, body]
end
def titulo(t)
puts "\n#{'─' * 66}\n#{t}\n#{'─' * 66}"
end
# ── 1. Leitura: o que cada endpoint devolve de imagem ────────────────────────
titulo("1. GET /v1/routes/visits/#{visita_id}/")
cod, visita = requisicao(:get, "/v1/routes/visits/#{visita_id}/")
sair("Falhou (HTTP #{cod}): #{visita.inspect}") unless cod == 200
pictures_orig = Array(visita['pictures'])
puts " status .............. #{visita['status'].inspect}"
puts " reference (NF) ...... #{visita['reference'].inspect}"
puts " pictures ............ #{pictures_orig.size} item(ns)"
pictures_orig.each_with_index { |u, i| puts " [#{i}] #{u}" }
puts " signature ........... #{visita['signature'].inspect}"
titulo("2. GET /v1/plans/visits/#{visita_id}/detail/ (fonte extra de fotos)")
cod_d, detalhe = requisicao(:get, "/v1/plans/visits/#{visita_id}/detail/")
if cod_d == 200 && detalhe.is_a?(Hash)
puts " OK. Chaves: #{detalhe.keys.sort.join(', ')}"
puts " pictures ............ #{Array(detalhe['pictures']).size} item(ns)"
Array(detalhe['pictures']).each_with_index { |u, i| puts " [#{i}] #{u}" }
puts " signature ........... #{detalhe['signature'].inspect}"
else
puts " Indisponível (HTTP #{cod_d}) — a galeria segue só com a visita."
end
unless testar_escrita
titulo('Leitura concluída — nada foi alterado')
puts ' Para descobrir se dá para GRAVAR foto, rode de novo com --testar-escrita'
puts ' (de preferência numa visita de teste).'
exit 0
end
# ── 3. Escrita: tenta acrescentar uma foto e restaura ────────────────────────
titulo('3. TESTE DE ESCRITA — PATCH pictures')
puts " Valor ORIGINAL (guarde para restaurar na mão, se precisar):"
puts " #{pictures_orig.to_json}"
veredito = 'INDEFINIDO'
begin
novo = pictures_orig + [URL_TESTE]
cod_p, resp = requisicao(:patch, "/v1/routes/visits/#{visita_id}/", { 'pictures' => novo })
puts "\n PATCH respondeu HTTP #{cod_p}"
if cod_p != 200
veredito = 'NÃO É POSSÍVEL — a API recusou o PATCH'
puts " corpo: #{resp.inspect[0, 400]}"
else
# 200 não basta: a API pode aceitar e ignorar o campo silenciosamente.
_, confere = requisicao(:get, "/v1/routes/visits/#{visita_id}/")
persistiu = Array(confere['pictures']).include?(URL_TESTE)
veredito = persistiu ? 'É POSSÍVEL — a foto de teste persistiu' :
'NÃO — a API respondeu 200 mas IGNOROU o campo pictures'
puts " pictures após o PATCH: #{Array(confere['pictures']).size} item(ns)"
end
ensure
# Restaura sempre, mesmo se algo acima estourar.
titulo('4. RESTAURANDO o valor original')
cod_r, = requisicao(:patch, "/v1/routes/visits/#{visita_id}/", { 'pictures' => pictures_orig })
_, final = requisicao(:get, "/v1/routes/visits/#{visita_id}/")
restaurado = Array(final['pictures'])
ok = restaurado == pictures_orig && !restaurado.include?(URL_TESTE)
puts " PATCH de restauração: HTTP #{cod_r}"
puts(ok ? ' ✔ Restaurado — a visita voltou ao estado original.' :
" ✘ ATENÇÃO: confira manualmente. Agora está: #{restaurado.to_json}")
end
titulo("VEREDITO: #{veredito}")
puts <<~FIM
Se deu "É POSSÍVEL", dá para implementar substituir/remover/adicionar foto —
faltando só resolver ONDE hospedar a imagem nova (o app ainda não tem
ActiveStorage com migrations/storage.yml).
Se deu "NÃO", a alteração de foto terá de ser feita pela interface web do
SimpliRoute, ou via o endpoint de checkout do app do motorista
(POST /v1/mobile/visit/#{visita_id}/checkout/), que reescreve o checkout
inteiro — bem mais invasivo. Vale confirmar com contact@simpliroute.com.
FIM