Files
Reem-Notas/app/services/romaneios/importador.rb

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