# 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'] } visitas = com_tracking_id(visitas) 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) return nil if tracking_id.blank? entregas.find { |e| e.tracking_id.to_s == tracking_id.to_s } end # Quantas visitas da lista vale a pena detalhar (1 GET cada). Uma NF com mais # que isso é dado estranho, não plano duplicado. MAX_DETALHES_LISTA = 5 # A lista-por-data nem sempre traz o `tracking_id`, e é ele que liga a visita # ao espelho — de onde vêm motorista e veículo. Sem esse casamento a NF # duplicada aparece com as duas linhas sem motorista, que é exatamente o dado # que distingue uma da outra. Quando faltar, busca a visita completa. def com_tracking_id(visitas) visitas.each_with_index.map do |visita, i| next visita if visita['tracking_id'].present? || i >= MAX_DETALHES_LISTA detalhar(visita) end end # Best-effort: se o detalhe falhar, segue com o que a lista deu. def detalhar(visita) client.visita(visita['id']).presence || visita rescue SimpliRoute::Error visita 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, # Sem linha no espelho não há como saber motorista/veículo: a API não # devolve esses campos na visita. O ADM diferencia pelo status/checkout. no_painel: entrega.present?, aviso: entrega ? nil : 'Ainda não sincronizado no painel — motorista e veículo indisponíveis' } 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, no_painel: true, aviso: 'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar' } 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 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