# app/services/romaneios/importador.rb # # O ÚNICO lugar que sabe reconciliar um plano recebido com o romaneio já editado. # Os dois caminhos de entrada (API do SimpliRoute e upload do .xlsx) entregam a # mesma forma de linha e caem aqui — senão a mesma parada importada por caminhos # diferentes viraria duas. # # ── A REGRA (dirty tracking POR CAMPO, não por linha) ─────────────────────── # 1. Identidade = chave_origem (NF, ou digest do título+endereço). Veículo e ordem # NÃO entram: eles mudam quando o plano é reotimizado e a parada continua sendo # a mesma entrega. # 2. Linha que já existe: `origem` (o snapshot do plano) é SEMPRE atualizada; os # campos são reescritos SÓ se o operador não os tiver tocado. # 3. Linha nova: insere. # 4. Linha que sumiu do plano: sem edição humana, apaga; com edição, marca # `removido_em` — apagar levaria junto o trabalho sem deixar rastro na tela. # 5. NF repetida no plano: fica a primeira, as outras são reportadas. # # Por que POR CAMPO e não um "bloqueado" na linha inteira: a edição comum é "o # operador corrigiu um telefone". Travar a linha congelaria um endereço velho quando # o SimpliRoute regeocodificasse, e o papel sairia com um endereço que o motorista # não acha. Por campo é a única regra em que "corrigi o telefone" não significa # "também respondo pelo endereço". module Romaneios class Importador Resultado = Struct.new(:novos, :atualizados, :preservados, :saidos, :duplicados, :veiculos, :colunas_ausentes, keyword_init: true) do def to_h super.transform_values { |v| v.is_a?(Array) ? v : v.to_i } end # Frase que a tela mostra depois de importar. Números soltos não dizem se # deu certo; esta linha diz. def resumo partes = ["#{novos} nova(s)", "#{atualizados} atualizada(s)"] partes << "#{preservados} mantida(s) por edição" if preservados.to_i.positive? partes << "#{saidos} saíram do plano" if saidos.to_i.positive? partes << "#{duplicados} NF repetida(s) no plano" if duplicados.to_i.positive? partes.join(' · ') end end def initialize(romaneio:, linhas:, usuario: nil, origem: nil) @romaneio = romaneio @recebidas = Array(linhas) @usuario = usuario @origem = origem end def aplicar! novas, duplicados = deduplicar(@recebidas) dados = enriquecer(novas) novos = atualizados = preservados = saidos = 0 vistas = [] ActiveRecord::Base.transaction do existentes = @romaneio.romaneio_linhas.index_by(&:chave_origem) dados.each do |linha| vistas << linha[:chave_origem] registro = existentes[linha[:chave_origem]] if registro.nil? criar(linha) novos += 1 else preservados += atualizar(registro, linha) atualizados += 1 end end saidos = remover_ausentes(existentes, vistas) sincronizar_veiculos(dados) @romaneio.update!( origem: @origem || @romaneio.origem, importado_em: Time.current, importado_por: @usuario || @romaneio.importado_por ) end # Fora do bloco de propósito: `return` dentro de uma transaction do Rails 7 # COMMITA em silêncio, o que é uma pegadinha esperando um leitor distraído. Resultado.new( novos: novos, atualizados: atualizados, preservados: preservados, saidos: saidos, duplicados: duplicados, veiculos: dados.map { |l| l[:veiculo] }.uniq.size, colunas_ausentes: @colunas_ausentes || [] ) end private # A primeira ocorrência vence. Plano duplicado no SimpliRoute gera visita nova # com a MESMA reference — repetir a parada no papel faria o motorista levar dois # pacotes para o mesmo endereço. def deduplicar(linhas) vistas = {} repetidas = 0 linhas.each do |linha| chave = RomaneioLinha.chave_para(nota_fiscal: linha[:nota_fiscal], titulo: linha[:titulo], endereco: linha[:endereco]) if vistas.key?(chave) repetidas += 1 next end vistas[chave] = linha.merge(chave_origem: chave) end [vistas.values, repetidas] end # Aplica o que a operação sabe por cima do que o plano trouxe. # Precedência (decidida com o usuário): operação > plano. def enriquecer(linhas) tabela = @romaneio.operacao_tabela.to_s return linhas if tabela.empty? gade = EnriquecimentoGade.new(tabela) @colunas_ausentes = gade.colunas_ausentes dados = gade.para_notas(linhas.map { |l| l[:nota_fiscal] }) linhas.map do |linha| extra = dados[linha[:nota_fiscal].to_s] || {} linha.merge( aparelho: extra[:aparelho].to_s, # A coluna `telefones` da operação é a fonte completa; o "TEL:" das # anotações do plano só entra quando ela não tem o número. telefones: extra[:telefones].to_s.empty? ? linha[:telefones].to_s : extra[:telefones], nome: extra[:nome].to_s.empty? ? linha[:nome].to_s : extra[:nome] ) end rescue ArgumentError # Operação removida do banco entre a criação do romaneio e a reimportação: # segue com o que o plano trouxe, e a tela avisa. @colunas_ausentes = EnriquecimentoGade::COLUNAS.keys linhas end def campos(linha) { 'nota_fiscal' => linha[:nota_fiscal].to_s, 'nome' => linha[:nome].to_s, 'endereco' => linha[:endereco].to_s, 'aparelho' => linha[:aparelho].to_s, 'telefones' => linha[:telefones].to_s } end def criar(linha) valores = campos(linha) @romaneio.romaneio_linhas.create!( valores.merge( veiculo: linha[:veiculo], ordem: linha[:ordem], chave_origem: linha[:chave_origem], origem: valores, campos_editados: [] ) ) end # Devolve quantos campos foram PRESERVADOS por terem edição humana. def atualizar(registro, linha) valores = campos(linha) preservados = 0 valores.each do |campo, valor| if registro.editado?(campo) preservados += 1 next end registro[campo] = valor end # Veículo e ordem são a verdade do roteiro, não conteúdo do documento — # seguem o plano mesmo numa linha editada. registro.veiculo = linha[:veiculo] registro.ordem = linha[:ordem] registro.origem = valores registro.removido_em = nil registro.save! preservados end def remover_ausentes(existentes, vistas) ausentes = existentes.except(*vistas).values return 0 if ausentes.empty? limpas, editadas = ausentes.partition { |l| l.campos_editados.blank? } RomaneioLinha.where(id: limpas.map(&:id)).delete_all if limpas.any? RomaneioLinha.where(id: editadas.map(&:id)).update_all(removido_em: Time.current) if editadas.any? ausentes.size end # Cria a folha de cada veículo novo e reordena as páginas. O `motorista` já # preenchido NÃO é tocado: ele é digitado na tela, não vem do plano (o export # real traz a coluna vazia em todas as linhas). def sincronizar_veiculos(linhas) ordem = linhas.map { |l| l[:veiculo] }.uniq.sort existentes = @romaneio.romaneio_veiculos.index_by(&:veiculo) ordem.each_with_index do |veiculo, i| registro = existentes[veiculo] || @romaneio.romaneio_veiculos.build(veiculo: veiculo) registro.posicao = i motorista = linhas.find { |l| l[:veiculo] == veiculo && l[:motorista].to_s != '' } registro.motorista = motorista[:motorista] if motorista && registro.motorista.to_s.empty? registro.save! end # Veículo que saiu do plano e ficou sem nenhuma parada não tem folha para # imprimir. sobrando = existentes.keys - ordem @romaneio.romaneio_veiculos.where(veiculo: sobrando).destroy_all if sobrando.any? end end end