diff --git a/.env.example b/.env.example index 4075211..d07d150 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/controllers/admin/edicao_lancamentos_controller.rb b/app/controllers/admin/edicao_lancamentos_controller.rb new file mode 100644 index 0000000..dbb4568 --- /dev/null +++ b/app/controllers/admin/edicao_lancamentos_controller.rb @@ -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 diff --git a/app/policies/edicao_lancamento_policy.rb b/app/policies/edicao_lancamento_policy.rb new file mode 100644 index 0000000..481ec53 --- /dev/null +++ b/app/policies/edicao_lancamento_policy.rb @@ -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 diff --git a/app/services/simpli_route/client.rb b/app/services/simpli_route/client.rb new file mode 100644 index 0000000..282b6dd --- /dev/null +++ b/app/services/simpli_route/client.rb @@ -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 diff --git a/app/views/admin/edicao_lancamentos/show.html.erb b/app/views/admin/edicao_lancamentos/show.html.erb new file mode 100644 index 0000000..f9d45fc --- /dev/null +++ b/app/views/admin/edicao_lancamentos/show.html.erb @@ -0,0 +1,190 @@ +<%# app/views/admin/edicao_lancamentos/show.html.erb %> +
+ + <%# Header %> +
+

Editar Lançamento

+

Corrige status, motivo e comentários de uma entrega direto no SimpliRoute

+
+ + <%# Flash %> + <%= render 'shared/flash' %> + + <%# Aviso de sincronização %> +
+ ⚠️ +

As alterações são gravadas no SimpliRoute na hora, mas o painel e as + consolidações só refletem após a próxima sincronização da base de rastreio. + Toda alteração fica registrada na auditoria.

+
+ + <%# Busca por NF %> +
+ +
+ + +
+ + +
+ + <%# 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 %> + + + <%# Cabeçalho da visita (somente leitura) %> +
+

+

+

NF · visita

+
+ +
+ <%# Status %> +
+ + +
+ + <%# Motivo (só faz sentido em falha) %> +
+ + +
+
+ + <%# Comentário livre %> +
+ + +
+ + <%# Observações internas %> +
+ + +
+ + <%# Data/geo do checkout (avançado) %> +
+ Data e localização do checkout (avançado) +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ <% end %> +
+ + diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 74f76b1..30892b4 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -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) %> diff --git a/config/initializers/simpli_route.rb b/config/initializers/simpli_route.rb new file mode 100644 index 0000000..56ab32b --- /dev/null +++ b/config/initializers/simpli_route.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 299ba8a..e370bca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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