Impĺantação Da função de alteração de cada lancamento no banco da simplerout
This commit is contained in:
@@ -18,6 +18,14 @@ DB_NAME=logistica_db # nome do banco NOVO que o sistema vai criar
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=senha_segura
|
||||
|
||||
# ── SimpliRoute — API de escrita (edição de lançamentos pelo ADM) ─────
|
||||
# A base de rastreio (DB_EXISTING_TABLE) é só LEITURA e sincroniza a partir
|
||||
# do SimpliRoute. Para CORRIGIR um lançamento (status/motivo/comentário) o
|
||||
# sistema grava direto na API do SimpliRoute usando este token.
|
||||
# Pegue em: SimpliRoute → Configurações → API. NUNCA versione o token real.
|
||||
SIMPLIROUTE_TOKEN=
|
||||
SIMPLIROUTE_BASE_URL=https://api.simpliroute.com
|
||||
|
||||
# ── Tabela existente (apenas leitura — NÃO modificar) ────────
|
||||
DB_EXISTING_TABLE=db_reem_simplerout_2026
|
||||
DB_EXISTING_SCHEMA=public
|
||||
|
||||
176
app/controllers/admin/edicao_lancamentos_controller.rb
Normal file
176
app/controllers/admin/edicao_lancamentos_controller.rb
Normal file
@@ -0,0 +1,176 @@
|
||||
# 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 o estado atual.
|
||||
def buscar
|
||||
authorize :edicao_lancamento
|
||||
|
||||
nf = params[:nf].to_s.strip
|
||||
return render_erro('Informe o número da NF.') if nf.blank?
|
||||
|
||||
entrega = Entrega.por_nf(nf).first
|
||||
return render_erro("NF #{nf} não encontrada na base de rastreio.") if entrega.nil?
|
||||
|
||||
id = client.resolver_id(entrega)
|
||||
visita = client.visita(id)
|
||||
|
||||
render json: {
|
||||
ok: true,
|
||||
visita: {
|
||||
id: 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']
|
||||
},
|
||||
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
|
||||
def atualizar
|
||||
authorize :edicao_lancamento
|
||||
|
||||
id = params[:visit_id].to_s.strip
|
||||
return redirect_to(admin_edicao_lancamento_path, alert: 'Visita não identificada.') if id.blank?
|
||||
|
||||
anterior = client.visita(id)
|
||||
attrs = mudancas(anterior)
|
||||
|
||||
if attrs.empty?
|
||||
return redirect_to(admin_edicao_lancamento_path, alert: 'Nenhuma alteração informada.')
|
||||
end
|
||||
if attrs['status'].present? && STATUS_VALIDOS.exclude?(attrs['status'])
|
||||
return redirect_to(admin_edicao_lancamento_path, alert: '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
|
||||
)
|
||||
|
||||
redirect_to admin_edicao_lancamento_path,
|
||||
notice: "Lançamento da NF #{atualizada['reference']} atualizado no SimpliRoute. " \
|
||||
'O painel refletirá na próxima sincronização.'
|
||||
rescue SimpliRoute::Error => e
|
||||
redirect_to admin_edicao_lancamento_path, alert: "Não foi possível atualizar: #{e.message}"
|
||||
end
|
||||
|
||||
# GET /admin/edicao_lancamento/historico?visit_id=123 → JSON (best-effort).
|
||||
def historico
|
||||
authorize :edicao_lancamento
|
||||
render json: { ok: true, historico: client.historico(params[:visit_id]) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client
|
||||
@client ||= SimpliRoute::Client.new
|
||||
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
|
||||
|
||||
# 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
|
||||
11
app/policies/edicao_lancamento_policy.rb
Normal file
11
app/policies/edicao_lancamento_policy.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# app/policies/edicao_lancamento_policy.rb
|
||||
#
|
||||
# Correção de lançamento grava DIRETO na base de produção do SimpliRoute —
|
||||
# operação sensível e irreversível. Restrita a admin.
|
||||
# Policy "headless": autorizada com o símbolo :edicao_lancamento (sem model).
|
||||
class EdicaoLancamentoPolicy < ApplicationPolicy
|
||||
def show? = admin?
|
||||
def buscar? = admin?
|
||||
def atualizar? = admin?
|
||||
def historico? = admin?
|
||||
end
|
||||
124
app/services/simpli_route/client.rb
Normal file
124
app/services/simpli_route/client.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
# app/services/simpli_route/client.rb
|
||||
#
|
||||
# Cliente HTTP da API do SimpliRoute (base de ESCRITA). A tabela de rastreio
|
||||
# local (model Entrega) é só leitura e sincroniza a partir daqui; para corrigir
|
||||
# um lançamento o ADM grava direto por este cliente.
|
||||
#
|
||||
# Usa Net::HTTP (stdlib) — o projeto não tem Faraday/HTTParty.
|
||||
#
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
|
||||
module SimpliRoute
|
||||
class Error < StandardError; end
|
||||
|
||||
# Erro esperado/apresentável ao usuário (NF não achada, ambiguidade, etc.).
|
||||
class NotFound < Error; end
|
||||
|
||||
class Client
|
||||
# Motivos de insucesso são fixos (todos type=failed). Cache curto porque
|
||||
# muda raríssimo, mas evita bater na API a cada abertura de tela.
|
||||
OBSERVATIONS_CACHE_KEY = 'simpliroute/observations'.freeze
|
||||
OBSERVATIONS_TTL = 1.hour
|
||||
|
||||
def initialize(token: SimpliRoute.token, base_url: SimpliRoute.base_url)
|
||||
raise Error, 'SIMPLIROUTE_TOKEN não configurado no ambiente.' if token.blank?
|
||||
|
||||
@token = token
|
||||
@base_uri = URI.parse(base_url)
|
||||
end
|
||||
|
||||
# Lista de motivos [{ 'id' => uuid, 'type' => 'failed', 'label' => 'ÓBITO' }, ...]
|
||||
def observations
|
||||
Rails.cache.fetch(OBSERVATIONS_CACHE_KEY, expires_in: OBSERVATIONS_TTL) do
|
||||
Array(get('/v1/routes/observations/'))
|
||||
end
|
||||
end
|
||||
|
||||
# Visita única (hash) pelo id numérico.
|
||||
def visita(id)
|
||||
get("/v1/routes/visits/#{id}/")
|
||||
end
|
||||
|
||||
# Todas as visitas de uma data (Array de hashes).
|
||||
def visitas_da_data(data)
|
||||
Array(get("/v1/routes/visits/?planned_date=#{data.to_date.iso8601}"))
|
||||
end
|
||||
|
||||
# Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega
|
||||
# do espelho local, que só tem tracking_id (SR...) + reference_id (NF) +
|
||||
# planned_date. Casa pelo tracking_id; se não achar, tenta pela NF.
|
||||
# Levanta NotFound (nada) ou Error (ambiguidade sem tracking_id).
|
||||
def resolver_id(entrega)
|
||||
data = entrega.planned_date&.to_date
|
||||
raise NotFound, 'Entrega sem planned_date — impossível localizar na API.' if data.blank?
|
||||
|
||||
visitas = visitas_da_data(data)
|
||||
|
||||
if entrega.tracking_id.present?
|
||||
achada = visitas.find { |v| v['tracking_id'].to_s == entrega.tracking_id.to_s }
|
||||
return achada['id'] if achada
|
||||
end
|
||||
|
||||
nf = entrega.reference_id.to_s
|
||||
por_nf = visitas.select { |v| v['reference'].to_s == nf }
|
||||
raise NotFound, "Visita da NF #{nf} não encontrada na API na data #{data.strftime('%d/%m/%Y')}." if por_nf.empty?
|
||||
if por_nf.size > 1
|
||||
raise Error, "Mais de uma visita para a NF #{nf} em #{data.strftime('%d/%m/%Y')} — não é possível resolver com segurança."
|
||||
end
|
||||
|
||||
por_nf.first['id']
|
||||
end
|
||||
|
||||
# Atualiza campos da visita (PATCH — só o que vier em `attrs`). Retorna a
|
||||
# visita atualizada (hash). Preferimos PATCH ao checkout para não sobrescrever
|
||||
# assinatura/geo originais sem intenção.
|
||||
def atualizar_visita(id, attrs)
|
||||
patch("/v1/routes/visits/#{id}/", attrs)
|
||||
end
|
||||
|
||||
# Histórico da visita (best-effort — a API pode devolver []).
|
||||
def historico(id)
|
||||
Array(get("/v1/routes/visits/#{id}/history/"))
|
||||
rescue Error
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get(path)
|
||||
requisicao(Net::HTTP::Get.new(caminho(path)))
|
||||
end
|
||||
|
||||
def patch(path, body)
|
||||
req = Net::HTTP::Patch.new(caminho(path))
|
||||
req.body = body.to_json
|
||||
requisicao(req)
|
||||
end
|
||||
|
||||
def caminho(path)
|
||||
URI.join(@base_uri.to_s, path)
|
||||
end
|
||||
|
||||
def requisicao(req)
|
||||
req['Authorization'] = "Token #{@token}"
|
||||
req['Content-Type'] = 'application/json'
|
||||
req['Accept'] = 'application/json'
|
||||
|
||||
res = Net::HTTP.start(@base_uri.host, @base_uri.port, use_ssl: @base_uri.scheme == 'https',
|
||||
open_timeout: 10, read_timeout: 30) do |http|
|
||||
http.request(req)
|
||||
end
|
||||
|
||||
unless res.is_a?(Net::HTTPSuccess)
|
||||
raise Error, "SimpliRoute respondeu #{res.code}: #{res.body.to_s.truncate(300)}"
|
||||
end
|
||||
|
||||
res.body.present? ? JSON.parse(res.body) : nil
|
||||
rescue JSON::ParserError => e
|
||||
raise Error, "Resposta inválida do SimpliRoute: #{e.message}"
|
||||
rescue Net::OpenTimeout, Net::ReadTimeout
|
||||
raise Error, 'Tempo esgotado ao falar com o SimpliRoute. Tente de novo.'
|
||||
end
|
||||
end
|
||||
end
|
||||
190
app/views/admin/edicao_lancamentos/show.html.erb
Normal file
190
app/views/admin/edicao_lancamentos/show.html.erb
Normal file
@@ -0,0 +1,190 @@
|
||||
<%# app/views/admin/edicao_lancamentos/show.html.erb %>
|
||||
<div class="space-y-6" data-buscar-url="<%= buscar_admin_edicao_lancamento_path %>">
|
||||
|
||||
<%# Header %>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Editar Lançamento</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">Corrige status, motivo e comentários de uma entrega direto no SimpliRoute</p>
|
||||
</div>
|
||||
|
||||
<%# Flash %>
|
||||
<%= render 'shared/flash' %>
|
||||
|
||||
<%# Aviso de sincronização %>
|
||||
<div class="p-4 bg-amber-900/20 border border-amber-500/40 rounded-xl text-amber-200/90 text-sm flex items-start gap-2">
|
||||
<span class="text-base leading-none">⚠️</span>
|
||||
<p>As alterações são gravadas no <strong>SimpliRoute na hora</strong>, mas o painel e as
|
||||
consolidações só refletem <strong>após a próxima sincronização</strong> da base de rastreio.
|
||||
Toda alteração fica registrada na auditoria.</p>
|
||||
</div>
|
||||
|
||||
<%# Busca por NF %>
|
||||
<div class="bg-[#111] border border-white/5 rounded-2xl p-5">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Buscar entrega pela NF</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="campo-nf" type="text" inputmode="numeric" placeholder="Ex: 79774"
|
||||
class="flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl px-4 py-3 text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
<button id="btn-buscar" type="button"
|
||||
class="px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl
|
||||
transition-colors min-h-[48px] whitespace-nowrap">
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
<p id="busca-erro" class="hidden mt-2 text-sm text-red-400"></p>
|
||||
<p id="busca-loading" class="hidden mt-2 text-sm text-gray-400">Consultando SimpliRoute…</p>
|
||||
</div>
|
||||
|
||||
<%# Formulário de edição (oculto até achar a visita) %>
|
||||
<%= form_with url: atualizar_admin_edicao_lancamento_path, method: :patch,
|
||||
html: { id: 'form-edicao', class: 'hidden bg-[#111] border border-white/5 rounded-2xl p-5 space-y-5' } do %>
|
||||
<input type="hidden" name="visit_id" id="f-visit-id">
|
||||
|
||||
<%# Cabeçalho da visita (somente leitura) %>
|
||||
<div class="border-b border-white/5 pb-4">
|
||||
<p class="text-white font-semibold" id="f-titulo">—</p>
|
||||
<p class="text-gray-500 text-sm" id="f-endereco">—</p>
|
||||
<p class="text-gray-600 text-xs mt-1">NF <span id="f-nf">—</span> · visita <span id="f-id">—</span></p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<%# Status %>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">Status</label>
|
||||
<select name="status" id="f-status"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
<% { 'completed' => 'Completa', 'failed' => 'Falha', 'pending' => 'Pendente',
|
||||
'partial' => 'Parcial', 'canceled' => 'Cancelada' }.each do |v, label| %>
|
||||
<option value="<%= v %>"><%= label %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<%# Motivo (só faz sentido em falha) %>
|
||||
<div id="wrap-motivo">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">Motivo (insucesso)</label>
|
||||
<select name="checkout_observation" id="f-motivo"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
<option value="">— sem motivo —</option>
|
||||
<% @motivos.each do |m| %>
|
||||
<option value="<%= m['id'] %>"><%= m['label'] %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Comentário livre %>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">Comentário do checkout</label>
|
||||
<textarea name="checkout_comment" id="f-comment" rows="2"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-orange-500"></textarea>
|
||||
</div>
|
||||
|
||||
<%# Observações internas %>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">Observações (notes)</label>
|
||||
<textarea name="notes" id="f-notes" rows="2"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-orange-500"></textarea>
|
||||
</div>
|
||||
|
||||
<%# Data/geo do checkout (avançado) %>
|
||||
<details class="rounded-xl border border-white/5 bg-[#0d0d0d] p-4">
|
||||
<summary class="cursor-pointer text-sm text-gray-400">Data e localização do checkout (avançado)</summary>
|
||||
<div class="grid sm:grid-cols-3 gap-4 mt-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1.5">Data/hora</label>
|
||||
<input type="datetime-local" name="checkout_time" id="f-checkout-time"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2.5 text-white
|
||||
focus:outline-none focus:border-orange-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1.5">Latitude</label>
|
||||
<input type="text" inputmode="decimal" name="checkout_latitude" id="f-lat"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2.5 text-white
|
||||
focus:outline-none focus:border-orange-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1.5">Longitude</label>
|
||||
<input type="text" inputmode="decimal" name="checkout_longitude" id="f-lng"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2.5 text-white
|
||||
focus:outline-none focus:border-orange-500">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit"
|
||||
class="px-6 py-3 bg-green-600 hover:bg-green-500 text-white font-semibold rounded-xl
|
||||
transition-colors min-h-[48px]">
|
||||
Salvar no SimpliRoute
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const wrap = document.querySelector('[data-buscar-url]');
|
||||
const buscarU = wrap.dataset.buscarUrl;
|
||||
const campo = document.getElementById('campo-nf');
|
||||
const btn = document.getElementById('btn-buscar');
|
||||
const erro = document.getElementById('busca-erro');
|
||||
const loading = document.getElementById('busca-loading');
|
||||
const form = document.getElementById('form-edicao');
|
||||
|
||||
const set = (id, v) => { document.getElementById(id).value = (v ?? ''); };
|
||||
const txt = (id, v) => { document.getElementById(id).textContent = (v ?? '—'); };
|
||||
|
||||
function toggleMotivo() {
|
||||
const falha = document.getElementById('f-status').value === 'failed';
|
||||
document.getElementById('wrap-motivo').style.opacity = falha ? '1' : '0.45';
|
||||
}
|
||||
document.getElementById('f-status').addEventListener('change', toggleMotivo);
|
||||
|
||||
async function buscar() {
|
||||
const nf = campo.value.trim();
|
||||
erro.classList.add('hidden');
|
||||
if (!nf) { erro.textContent = 'Informe o número da NF.'; erro.classList.remove('hidden'); return; }
|
||||
|
||||
loading.classList.remove('hidden');
|
||||
form.classList.add('hidden');
|
||||
try {
|
||||
const resp = await fetch(buscarU + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } });
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
erro.textContent = data.erro || 'Não foi possível localizar a entrega.';
|
||||
erro.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const v = data.visita;
|
||||
set('f-visit-id', v.id);
|
||||
txt('f-titulo', v.titulo);
|
||||
txt('f-endereco', v.endereco);
|
||||
txt('f-nf', v.nf);
|
||||
txt('f-id', v.id);
|
||||
set('f-status', v.status || 'completed');
|
||||
set('f-motivo', v.checkout_observation || '');
|
||||
set('f-comment', v.checkout_comment || '');
|
||||
set('f-notes', v.notes || '');
|
||||
set('f-lat', v.checkout_latitude ?? '');
|
||||
set('f-lng', v.checkout_longitude ?? '');
|
||||
// ISO -> valor de datetime-local (YYYY-MM-DDTHH:MM)
|
||||
set('f-checkout-time', v.checkout_time ? String(v.checkout_time).slice(0, 16) : '');
|
||||
toggleMotivo();
|
||||
form.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
erro.textContent = 'Erro de conexão ao buscar a NF.';
|
||||
erro.classList.remove('hidden');
|
||||
} finally {
|
||||
loading.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', buscar);
|
||||
campo.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
|
||||
})();
|
||||
</script>
|
||||
@@ -33,6 +33,9 @@
|
||||
|
||||
<%= nav_link_to 'Usuários', usuarios_path, icon: '👥' %>
|
||||
<%= nav_link_to 'Configurações', configuracoes_path, icon: '⚙️' %>
|
||||
<% if current_user.admin? %>
|
||||
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: '✏️' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%# PDFs (fase 7 — link desabilitado por enquanto) %>
|
||||
|
||||
20
config/initializers/simpli_route.rb
Normal file
20
config/initializers/simpli_route.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# config/initializers/simpli_route.rb
|
||||
#
|
||||
# Credenciais da API do SimpliRoute (base de escrita). A base de rastreio local
|
||||
# (model Entrega) é só leitura; para CORRIGIR um lançamento gravamos direto aqui.
|
||||
# Ver app/services/simpli_route/client.rb.
|
||||
module SimpliRoute
|
||||
# Token de API (ENV SIMPLIROUTE_TOKEN). Retorna nil se não configurado — o
|
||||
# Client levanta erro amigável ao ser usado sem token.
|
||||
def self.token
|
||||
ENV['SIMPLIROUTE_TOKEN'].presence
|
||||
end
|
||||
|
||||
def self.base_url
|
||||
ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com')
|
||||
end
|
||||
|
||||
def self.configurado?
|
||||
token.present?
|
||||
end
|
||||
end
|
||||
@@ -84,6 +84,14 @@ Rails.application.routes.draw do
|
||||
collection { post :toggle_tema }
|
||||
end
|
||||
resources :auditoria_logs, only: [:index]
|
||||
|
||||
# Edição de lançamento do SimpliRoute (correção de status/motivo/comentário)
|
||||
# — só admin. Grava direto na API do SimpliRoute + AuditoriaLog.
|
||||
resource :edicao_lancamento, only: [:show], controller: 'edicao_lancamentos' do
|
||||
get :buscar # NF -> dados da visita (JSON)
|
||||
patch :atualizar # aplica a alteração na API
|
||||
get :historico # histórico da visita na API (JSON, best-effort)
|
||||
end
|
||||
end
|
||||
|
||||
# API interna — Dashboard métricas
|
||||
|
||||
Reference in New Issue
Block a user