Correção de horario e implantação da geração de Romaneio

This commit is contained in:
2026-08-27 16:21:56 -03:00
parent a0aafe62fc
commit ad1a071e4b
51 changed files with 3450 additions and 46 deletions

View File

@@ -0,0 +1,101 @@
# app/services/romaneios/enriquecimento_gade.rb
#
# Preenche APARELHO, TELEFONES e NOME do romaneio a partir da tabela da operação
# (gade_entregas_*). É o que dispensa a SEGUNDA PLANILHA do programa Python: lá o
# operador subia um .xlsx só com NOTA FISCAL + STATUS para marcar quem é NOVO; aqui
# o STATUS já está no banco, na própria operação.
#
# A tabela é SOMENTE LEITURA (apenas SELECT), o nome passa por Operacao.valida? +
# quote_table_name e as NFs por conn.quote.
module Romaneios
class EnriquecimentoGade
# chave usada no SELECT => coluna real na gade.
#
# ⚠️ `status` PRECISA sair aliasado: o espelho de rastreio também tem uma coluna
# `status` (o estado da visita), e qualquer query que junte as duas trocaria uma
# pela outra em silêncio. Analytics::OperacaoMetricas já faz esse mesmo alias
# (GADE_OPCIONAIS) pelo mesmo motivo.
COLUNAS = {
'nota_fiscal' => 'nota_fiscal',
'nome_completo' => 'nome_completo',
'telefones' => 'telefones',
'status' => 'status_gade'
}.freeze
# Sem essas, não há o que enriquecer.
ESSENCIAIS = %w[nota_fiscal].freeze
attr_reader :tabela
def initialize(tabela)
@tabela = tabela.to_s
raise ArgumentError, "Operação inválida: #{@tabela}" unless Operacao.valida?(@tabela)
end
# { '90507' => { aparelho: 'SIM', telefones: '(11) ...', nome: 'CICERA ...' } }
# NF que não estiver na operação simplesmente não vem no Hash — é assim que a
# tela marca "fora da operação" (plano avulso / de inclusão), mesma semântica de
# Analytics::NotasForaOperacao.
def para_notas(notas)
lista = Array(notas).map { |n| n.to_s.strip }.reject(&:empty?).uniq
return {} if lista.empty?
registros(lista).each_with_object({}) do |r, memo|
nf = r['nota_fiscal'].to_s.strip
next if nf.empty?
memo[nf] = {
aparelho: Normalizador.aparelho_por_status(r['status_gade']),
telefones: r['telefones'].to_s.strip,
nome: Normalizador.nome(r['nome_completo'])
}
end
end
# Colunas que esta operação NÃO tem — a tela precisa DIZER isso.
#
# O esquema das gade_entregas_* varia por operação (só `nota_fiscal` é
# garantida). Quando falta `status`, o APARELHO sai vazio para TODO MUNDO — e um
# romaneio em que ninguém recebe aparelho, entregue calado, é pior do que um
# aviso feio na tela.
def colunas_ausentes
@colunas_ausentes ||= COLUNAS.keys - existentes
end
def utilizavel?
(ESSENCIAIS - existentes).empty?
end
def label
Operacao.label(@tabela)
end
private
def conn
ActiveRecord::Base.connection
end
def existentes
@existentes ||= conn.columns(@tabela).map(&:name)
end
def registros(lista)
return [] unless utilizavel?
valores = lista.map { |n| conn.quote(n) }.join(', ')
sql = "SELECT #{selects} FROM #{conn.quote_table_name(@tabela)} g " \
"WHERE g.nota_fiscal IN (#{valores})"
conn.select_all(sql).to_a
end
# Coluna que não existir vira NULL com o mesmo alias, preservando a forma do
# resultado — mesmo padrão de SimpliRoute::PlanilhaCarga#selects. As chaves de
# COLUNAS são whitelist fixa (não há entrada de usuário no SQL).
def selects
COLUNAS.map do |coluna, apelido|
existentes.include?(coluna) ? "g.#{coluna} AS #{apelido}" : "CAST(NULL AS text) AS #{apelido}"
end.join(', ')
end
end
end

View File

@@ -0,0 +1,215 @@
# 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

View File

@@ -0,0 +1,87 @@
# app/services/romaneios/normalizador.rb
#
# Parsing compartilhado entre as duas fontes de plano (API e .xlsx). Existe para os
# dois caminhos entregarem EXATAMENTE a mesma forma de linha ao Romaneios::Importador
# — se cada um normalizasse do seu jeito, o mesmo plano importado por caminhos
# diferentes geraria linhas com chave_origem diferente e a reconciliação duplicaria
# tudo na primeira reimportação.
#
# Namespace no plural (Romaneios::) para não colidir com a constante do model
# Romaneio.
module Romaneios
module Normalizador
# "NF 90507 - CICERA BESERRA DOS ANJOS" -> ['90507', 'CICERA BESERRA DOS ANJOS']
NF_TITULO = /\ANF\s+(\d+)\s*-\s*(.+)\z/i
# "TEL: (11) 99262-0594 / (11) 99824-8099" — o plano guarda o telefone dentro
# das Anotações, em texto livre. Pegamos TODOS os que casarem: uma parada com
# dois números precisa dos dois no papel (o motorista liga do portão).
TEL_ANOTACAO = /\(?\d{2}\)?\s*\d{4,5}-?\d{4}/
module_function
# Devolve [nf, nome]. Título fora do padrão vira [nil, título] — não é erro:
# plano avulso e de inclusão às vezes chegam sem o prefixo "NF n - ".
def nf_e_nome(titulo)
texto = titulo.to_s.strip
m = NF_TITULO.match(texto)
return [m[1], m[2].strip] if m
[nil, texto]
end
# O SimpliRoute devolve o endereço com sufixo ", Brazil"; o documento impresso
# nunca teve isso. Caixa alta porque é como o romaneio sai desde sempre.
def endereco(valor)
valor.to_s
.gsub(/,\s*Brazil\s*\z/i, '')
.strip
.gsub(/\s+/, ' ')
.upcase
end
# Telefones a partir das anotações do plano. Só é usado quando a operação não
# tem o número — a coluna `telefones` da gade_entregas_* é a fonte preferida
# (ver Romaneios::EnriquecimentoGade).
def telefones_de_anotacao(anotacoes)
texto = anotacoes.to_s
return '' unless texto.match?(/TEL/i)
texto.scan(TEL_ANOTACAO).map(&:strip).uniq.join(' / ')
end
def nome(valor)
valor.to_s.strip.gsub(/\s+/, ' ').upcase
end
# 'SIM' quando o paciente é NOVO na operação; vazio caso contrário.
# É o que a segunda planilha do programa Python fazia à mão.
def aparelho_por_status(status_gade)
status_gade.to_s.strip.casecmp('NOVO').zero? ? 'SIM' : ''
end
# Uma linha crua do plano (de qualquer fonte) vira a forma canônica que o
# Importador consome.
def linha(veiculo:, ordem:, titulo:, endereco:, anotacoes: nil,
nota_fiscal: nil, tracking_id: nil, motorista: nil)
# `endereco` aqui é a variável local do kwarg — o método homônimo continua
# acessível com parênteses, mas renomeamos para ninguém tropeçar.
endereco_cru = endereco
nf_titulo, nome_titulo = nf_e_nome(titulo)
nf = nota_fiscal.to_s.strip
nf = nf_titulo.to_s if nf.empty?
{
veiculo: veiculo.to_s.strip,
ordem: ordem.to_i,
motorista: motorista.to_s.strip,
nota_fiscal: nf.to_s,
nome: nome(nome_titulo),
endereco: endereco(endereco_cru),
telefones: telefones_de_anotacao(anotacoes),
tracking_id: tracking_id.to_s,
titulo: titulo.to_s
}
end
end
end

View File

@@ -0,0 +1,153 @@
# app/services/romaneios/plano_do_dia.rb
#
# Busca o plano de UMA data e devolve as paradas na forma canônica do
# Romaneios::Normalizador — a mesma que Romaneios::PlanoPlanilha entrega.
#
# ⚠️ NÃO está verificado se a visita de /v1/routes/visits/ carrega veículo/ordem, e
# isso não é testável na máquina de desenvolvimento (sem token, sem .env). Por isso
# aqui existe uma ESCADA DE QUEDA explícita, e não um caminho feliz único:
#
# a) rotas da API trazem vehicle + lista ordenada de visitas -> origem :api
# b) as visitas trazem alguma chave de veículo/ordem -> origem :api
# c) nada disso (ou sem token) -> ESPELHO local
# d) espelho vazio na data -> NotFound, com a
# mensagem que manda o operador usar o .xlsx do plano
#
# A rung (c) não é consolo: o romaneio é impresso ANTES do dia começar, e ela cobre
# "plano publicado e já sincronizado, mas a API fora do ar" — a falha mais provável.
# Rode bin/sondar_plano_do_dia no servidor para fechar (a)/(b).
module Romaneios
class PlanoDoDia
# Chaves candidatas nos hashes da API. Só as usamos se vierem PREENCHIDAS —
# a API devolve chave existente com valor nil o tempo todo.
CHAVES_VEICULO = %w[vehicle vehicle_name vehicle_id].freeze
CHAVES_MOTORISTA = %w[driver driver_name].freeze
CHAVES_ORDEM = %w[order sequence position route_order].freeze
attr_reader :origem
def initialize(data:, client: nil)
@data = data.to_date
@client = client
@origem = nil
end
def linhas
@linhas ||= carregar
end
private
def carregar
linhas = da_api
return linhas if linhas.any?
linhas = do_espelho
return linhas if linhas.any?
raise SimpliRoute::NotFound,
"O plano de #{@data.strftime('%d/%m/%Y')} não está disponível no SimpliRoute " \
'nem no espelho de rastreio. Envie a planilha do plano (.xlsx).'
end
# ── (a) e (b) ────────────────────────────────────────────────────────────
def da_api
return [] unless SimpliRoute.configurado?
cliente = @client || SimpliRoute::Client.new
visitas = cliente.visitas_da_data(@data)
return [] if visitas.empty?
linhas = por_rotas(cliente, visitas)
linhas = por_visitas(visitas) if linhas.empty?
@origem = 'api' if linhas.any?
linhas
rescue SimpliRoute::Error
# Token ausente/expirado ou API fora: cai para o espelho, que é a mesma
# informação já sincronizada.
[]
end
# (a) A rota traz o veículo e a lista de visitas na ordem do roteiro.
def por_rotas(cliente, visitas)
rotas = cliente.rotas_da_data(@data)
return [] if rotas.empty?
por_id = visitas.index_by { |v| v['id'] }
linhas = []
rotas.each do |rota|
veiculo = primeiro_valor(rota, CHAVES_VEICULO)
next if veiculo.empty?
ids = Array(rota['visits']).map { |v| v.is_a?(Hash) ? v['id'] : v }
ids = visitas.select { |v| v['route'].to_s == rota['id'].to_s }.map { |v| v['id'] } if ids.empty?
ids.each_with_index do |id, i|
visita = por_id[id]
next if visita.nil?
linhas << monta(visita, veiculo, primeiro_valor(rota, CHAVES_MOTORISTA), i + 1)
end
end
linhas
end
# (b) A visita traz o veículo direto; a ordem sai de `order`/`sequence` ou,
# na falta, do horário estimado de chegada.
def por_visitas(visitas)
com_veiculo = visitas.reject { |v| primeiro_valor(v, CHAVES_VEICULO).empty? }
return [] if com_veiculo.empty?
com_veiculo.group_by { |v| primeiro_valor(v, CHAVES_VEICULO) }.flat_map do |veiculo, lista|
ordenadas(lista).each_with_index.map do |visita, i|
monta(visita, veiculo, primeiro_valor(visita, CHAVES_MOTORISTA), i + 1)
end
end
end
def ordenadas(lista)
lista.sort_by.with_index do |v, i|
chave = CHAVES_ORDEM.filter_map { |k| v[k] }.first
[chave.nil? ? Float::INFINITY : chave.to_i, v['estimated_time_arrival'].to_s, i]
end
end
def monta(visita, veiculo, motorista, ordem)
Normalizador.linha(
veiculo: veiculo, ordem: ordem, motorista: motorista,
titulo: visita['title'], endereco: visita['address'],
anotacoes: visita['notes'], nota_fiscal: visita['reference'],
tracking_id: visita['tracking_id']
)
end
def primeiro_valor(hash, chaves)
chaves.filter_map { |k| hash[k] }.map(&:to_s).find { |v| !v.strip.empty? }.to_s.strip
end
# ── (c) Espelho local ────────────────────────────────────────────────────
# `db_reem_simplerout_2026` já tem driver, vehicle, title, address, notes e eta.
# Só leitura, como sempre.
def do_espelho
registros = Entrega.da_conta_gade
.no_periodo(@data, @data)
.where.not(vehicle: [nil, ''])
.order(:vehicle, :eta)
.to_a
return [] if registros.empty?
@origem = 'espelho'
registros.group_by(&:vehicle).flat_map do |veiculo, lista|
lista.each_with_index.map do |e, i|
Normalizador.linha(
veiculo: veiculo, ordem: i + 1, motorista: e.driver,
titulo: e.title, endereco: e.address, anotacoes: e.notes,
nota_fiscal: e.reference_id, tracking_id: e.tracking_id
)
end
end
end
end
end

View File

@@ -0,0 +1,140 @@
# app/services/romaneios/plano_planilha.rb
#
# Lê o .xlsx do PLANO exportado do SimpliRoute e devolve as linhas na mesma forma
# que SimpliRoute::PlanoDoDia — é o caminho B do romaneio, para quando a API não
# responde ou o plano do dia ainda não foi publicado.
#
# ⚠️ O arquivo NÃO é persistido em lugar nenhum. Ele tem nome, endereço e telefone
# de paciente; o ActiveStorage aqui é configurado condicionalmente e não há política
# de retenção para pendurar isso. Lemos do tempfile e descartamos.
require 'roo'
module Romaneios
class PlanoPlanilha
class Erro < StandardError; end
# Cabeçalhos aceitos, já normalizados (sem acento, minúsculo, espaço colapsado).
#
# ⚠️ Casamos por NOME e nunca por posição: o export do cliente varia. Medido no
# arquivo real (SimpliRoute_Plan_ 2026-08-31.xlsx, 2047 linhas / 72 veículos):
# a coluna "Motorista" vem VAZIA em todas as linhas e NÃO existem as colunas
# "Aparelho", "Pessoa de contato" nem "Telefone de contato" — que o programa
# Python original lia. Faltar qualquer uma delas é o caso NORMAL, não erro.
#
# "id de referencua" é um typo do próprio SimpliRoute; aceitamos os dois porque
# eles podem corrigir a qualquer momento e aí o romaneio pararia de achar a NF.
COLUNAS = {
veiculo: ['veiculo', 'vehicle'],
motorista: ['motorista', 'driver'],
indice: ['indice de rota', 'route index'],
ordem: ['ordem de entrega', 'delivery order'],
titulo: ['titulo', 'title'],
endereco: ['endereco', 'address'],
anotacoes: ['anotacoes', 'notes'],
nota_fiscal: ['id de referencua', 'id de referencia', 'reference id', 'nota fiscal']
}.freeze
TAMANHO_MAXIMO = 20 * 1024 * 1024
LINHAS_MAXIMO = 20_000
ASSINATURA_ZIP = "PK\x03\x04".b.freeze
# `entrada` é o tempfile do upload (ou um caminho, nos specs).
def initialize(entrada)
@caminho = caminho_de(entrada)
validar_arquivo!
end
def linhas
@linhas ||= montar
end
private
def caminho_de(entrada)
return entrada.tempfile.path if entrada.respond_to?(:tempfile)
return entrada.path if entrada.respond_to?(:path)
entrada.to_s
end
# Guardas antes de entregar o arquivo para a roo: um .xlsx é um zip, e um zip
# inesperado (ou gigante) vira consumo de memória sem limite dentro do processo
# que atende a requisição.
def validar_arquivo!
raise Erro, 'Envie a planilha do plano (.xlsx).' unless File.exist?(@caminho)
raise Erro, 'A planilha passa de 20 MB — não parece ser o plano do dia.' if File.size(@caminho) > TAMANHO_MAXIMO
assinatura = File.binread(@caminho, 4)
return if assinatura == ASSINATURA_ZIP
raise Erro, 'O arquivo não é um .xlsx. Baixe o plano do SimpliRoute em Excel e envie de novo.'
end
def montar
planilha = Roo::Excelx.new(@caminho)
cabecalho = mapear_colunas(planilha.row(1))
if cabecalho[:veiculo].nil?
raise Erro, 'A planilha não tem a coluna "Veículo" — confira se é o plano ' \
'do SimpliRoute e não a planilha de carga.'
end
total = planilha.last_row.to_i
raise Erro, "A planilha tem #{total} linhas — acima do limite de #{LINHAS_MAXIMO}." if total > LINHAS_MAXIMO
registros = (2..total).filter_map { |i| montar_linha(planilha.row(i), cabecalho, i) }
ordenar(registros)
ensure
planilha&.close if planilha.respond_to?(:close)
end
# { chave => índice da coluna }. Coluna ausente simplesmente não entra no hash.
def mapear_colunas(cabecalho)
normalizados = Array(cabecalho).map { |c| normalizar_cabecalho(c) }
COLUNAS.each_with_object({}) do |(chave, aceitos), memo|
idx = normalizados.index { |c| aceitos.include?(c) }
memo[chave] = idx if idx
end
end
# Sem acento, minúsculo, espaço colapsado — o export alterna maiúscula/minúscula
# e o acento varia com a versão do arquivo.
def normalizar_cabecalho(valor)
I18n.transliterate(valor.to_s).downcase.strip.gsub(/\s+/, ' ')
end
def montar_linha(linha, cabecalho, numero_da_linha)
valor = ->(chave) { cabecalho[chave] ? linha[cabecalho[chave]] : nil }
veiculo = valor.call(:veiculo).to_s.strip
titulo = valor.call(:titulo).to_s.strip
ender = valor.call(:endereco).to_s.strip
# Linha sem veículo, ou sem nada que identifique a parada, é lixo de rodapé
# do próprio export — pular em silêncio é o certo.
return nil if veiculo.empty? || (titulo.empty? && ender.empty?)
Normalizador.linha(
veiculo: veiculo,
# A ordem definitiva é atribuída em #ordenar; aqui guardamos o que o
# arquivo trouxer para poder ordenar depois.
ordem: valor.call(:ordem) || valor.call(:indice) || numero_da_linha,
titulo: titulo,
endereco: ender,
anotacoes: valor.call(:anotacoes),
nota_fiscal: valor.call(:nota_fiscal),
motorista: valor.call(:motorista)
)
end
# Ordem final: por veículo, pela ordem de entrega do plano. Renumeramos de 1 a N
# dentro de cada veículo porque é isso que vira a coluna "#" do papel — e o
# "Índice de rota" do export não é 1..N por veículo.
def ordenar(registros)
registros.group_by { |r| r[:veiculo] }.flat_map do |_veiculo, linhas|
linhas.sort_by.with_index { |r, i| [r[:ordem].to_i, i] }
.each_with_index.map { |r, i| r.merge(ordem: i + 1) }
end
end
end
end