216 lines
8.0 KiB
Ruby
216 lines
8.0 KiB
Ruby
# 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
|