# 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. # # Aceita ?de=&ate= para varrer um período (a API só filtra por UM dia, então # varremos dia a dia). Sem período, usa as datas que o espelho conhece. 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 datas = datas_de_busca(entregas) return render_erro(sem_datas_msg(nf, entregas)) if datas.empty? falhas = [] ocorrencias = ocorrencias_da_nf(nf, entregas, datas, falhas) unica = ocorrencias.first if ocorrencias.one? && ocorrencias.first[:id].present? render json: { ok: true, ocorrencias: ocorrencias, visita: (unica && payload_seguro(unica, entregas, falhas)), periodo: { de: datas.first, ate: datas.last, dias: datas.size }, # Dias que a API não respondeu. Vão para a tela: uma busca que devolve # menos do que existe TEM que dizer que devolveu menos. falhas: falhas, 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 ) # Mudança na operação feita POR DENTRO do sistema. A maioria das mudanças # vem de fora e é pega pelo DetectarMudancasOperacaoJob; esta chega na hora # e sabe exatamente qual NF e o que mudou. Notificacao::Gatilhos.operacao_alterada( operacao: anterior['title'].presence || 'SimpliRoute', o_que_mudou: descrever_mudanca(anterior, atualizada, attrs.keys), nf: anterior['reference'] ) 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 # "status: pending → completed; observação: … → ÓBITO" — o texto que vai para # a variável {{o_que_mudou}} do editor de blocos. def descrever_mudanca(anterior, atualizada, campos) Array(campos).filter_map do |campo| de = anterior[campo].to_s.strip para = atualizada[campo].to_s.strip next if de == para "#{campo}: #{de.presence || '(vazio)'} → #{para.presence || '(vazio)'}" end.join('; ').presence || 'lançamento atualizado' end def client @client ||= SimpliRoute::Client.new end # Máximo de dias varridos numa busca por período. A API só filtra por UM dia, # então cada dia é uma chamada — com `search` cada uma custa ~0,6 s e elas vão # em lotes paralelos, o que torna dois meses viável; o teto existe para a tela # não virar uma varredura sem fim. MAX_DIAS_VARREDURA = 62 # Quais dias consultar na API: # • com ?de=/?ate= — o período pedido (o SimpliRoute duplica plano para # outra data, e aí o espelho não tem como saber qual é); # • sem período — as datas que o espelho conhece (barato: 1 ou 2 chamadas). def datas_de_busca(entregas) de = data_param(:de) ate = data_param(:ate) if de || ate inicio, fim = [de || ate, ate || de].minmax (inicio..fim).first(MAX_DIAS_VARREDURA) else entregas.filter_map { |e| e.planned_date&.to_date }.uniq.sort end end def data_param(chave) valor = params[chave].to_s.strip return nil if valor.blank? Date.parse(valor) rescue Date::Error nil end # Sem datas não há o que consultar — e o motivo muda a saída para o ADM. def sem_datas_msg(nf, entregas) if entregas.empty? "NF #{nf} não encontrada na base de rastreio. Informe um período para procurar direto no SimpliRoute." else "NF #{nf} está na base de rastreio mas sem data planejada. Informe um período para procurar na API." end end # Todos os lançamentos da NF, cruzando as DUAS fontes: # • espelho de rastreio (Entrega) — traz motorista/veículo; # • API do SimpliRoute, nas datas varridas — pega a visita duplicada que a # sincronização ainda não trouxe para o espelho. def ocorrencias_da_nf(nf, entregas, datas, falhas) trackings = entregas.map { |e| e.tracking_id.to_s } do_dia = visitas_dos_dias(datas, nf, falhas) 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, datas.include?(e.planned_date&.to_date)) } # 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 # Dias consultados de uma vez. Cada chamada abre a própria conexão HTTP (ver # SimpliRoute::Client#requisicao) e não toca o banco, então dá para # paralelizar em lotes; o lote pequeno evita martelar a API do SimpliRoute. MAX_PARALELO_VARREDURA = 6 # Passa a NF como `search` — sem isso cada dia baixaria ~3,8 MB (o dia inteiro) # e um mês seria inviável. Ver a nota em SimpliRoute::Client#visitas_da_data. def visitas_dos_dias(datas, nf, falhas) mutex = Mutex.new datas.each_slice(MAX_PARALELO_VARREDURA).flat_map do |lote| lote.map { |data| Thread.new { visitas_do_dia(data, nf, falhas, mutex) } }.flat_map(&:value) end end # Um dia que a API não responde NÃO pode derrubar a busca inteira: registra a # falha e segue com os outros dias. (Foi assim que uma NF com visitas em duas # datas passou a não devolver nada em vez de devolver o que deu certo.) def visitas_do_dia(data, nf, falhas, mutex) client.visitas_da_data(data, busca: nf) rescue SimpliRoute::Error => e mutex.synchronize { falhas << { data: data.strftime('%d/%m/%Y'), erro: e.message } } [] end # Abrir o card do lançamento único é conveniência: se falhar, a lista ainda # tem que aparecer. def payload_seguro(ocorrencia, entregas, falhas) payload_visita(ocorrencia[:id], entrega_de(entregas, ocorrencia[:tracking_id])) rescue SimpliRoute::Error => e falhas << { data: ocorrencia[:data].to_s[0, 10], erro: "não foi possível abrir o lançamento: #{e.message}" } nil 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, data_varrida) { 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: (if data_varrida 'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar' else 'Fora do período consultado — inclua a data deste lançamento no período para poder editá-lo' end) } 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