Correção de horario e implantação da geração de Romaneio
This commit is contained in:
@@ -9,16 +9,15 @@ module Pdf
|
||||
PRETO = '0A0A0A'
|
||||
CINZA = '6B7280'
|
||||
|
||||
# Fonte TrueType com suporte total a UTF-8. As fontes embutidas do Prawn
|
||||
# (AFM/Helvetica) só aceitam o charset Windows-1252 e estouram
|
||||
# Prawn::Errors::IncompatibleStringEncoding em caracteres como "✓".
|
||||
FONTS_DIR = Rails.root.join('app', 'assets', 'fonts')
|
||||
# Fonte TrueType com suporte total a UTF-8 — o porquê está em Pdf::FonteUtf8,
|
||||
# que também serve o romaneio (paisagem, marca da GADE, sem herdar daqui).
|
||||
include Pdf::FonteUtf8
|
||||
|
||||
def initialize
|
||||
# Margem inferior folgada (72) para o rodapé repetido (desenhado em y=20)
|
||||
# não sobrepor o conteúdo.
|
||||
@pdf = Prawn::Document.new(page_size: 'A4', margin: [40, 40, 72, 40])
|
||||
registrar_fonte_utf8
|
||||
registrar_fonte_utf8(@pdf)
|
||||
end
|
||||
|
||||
def render
|
||||
@@ -30,24 +29,6 @@ module Pdf
|
||||
|
||||
private
|
||||
|
||||
# Registra a família DejaVu Sans (TTF) e a define como fonte padrão.
|
||||
# Como os PDFs só usam normal e :bold, mapeamos italic/bold_italic para
|
||||
# as variantes existentes, evitando erro caso :italic seja solicitado.
|
||||
def registrar_fonte_utf8
|
||||
normal = FONTS_DIR.join('DejaVuSans.ttf').to_s
|
||||
bold = FONTS_DIR.join('DejaVuSans-Bold.ttf').to_s
|
||||
|
||||
@pdf.font_families.update(
|
||||
'DejaVu' => {
|
||||
normal: normal,
|
||||
bold: bold,
|
||||
italic: normal,
|
||||
bold_italic: bold
|
||||
}
|
||||
)
|
||||
@pdf.font 'DejaVu'
|
||||
end
|
||||
|
||||
def cabecalho
|
||||
@pdf.fill_color PRETO
|
||||
@pdf.fill_rectangle [-40, @pdf.cursor + 40], @pdf.bounds.width + 80, 70
|
||||
|
||||
42
app/services/pdf/fonte_utf8.rb
Normal file
42
app/services/pdf/fonte_utf8.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# app/services/pdf/fonte_utf8.rb
|
||||
#
|
||||
# Registro da família DejaVu Sans (TrueType) num Prawn::Document.
|
||||
#
|
||||
# Por que existe: as fontes embutidas do Prawn (AFM/Helvetica) só aceitam o charset
|
||||
# Windows-1252 e estouram Prawn::Errors::IncompatibleStringEncoding em qualquer
|
||||
# caractere fora dele ("✓" no extrato do motorista; e, no romaneio, endereços que
|
||||
# chegam do cliente com "SÃO PAULO", "INDAIÁ", "JARDIM MIRIAM").
|
||||
#
|
||||
# Estava privado em Pdf::BasePdf, mas o motivo não tem nada a ver com o layout
|
||||
# retrato/preto-laranja da Reem — vale para todo PDF do app. Pdf::RomaneioPdf é
|
||||
# paisagem com a marca da GADE e não herda de BasePdf, então precisa do mesmo
|
||||
# registro sem arrastar o cabeçalho junto.
|
||||
module Pdf
|
||||
module FonteUtf8
|
||||
# Resolvido em tempo de CHAMADA, não de carga: Pdf::RomaneioPdf precisa ser
|
||||
# `require`-ável num container Ruby pelado (sem Rails) para a conferência de
|
||||
# layout em PNG — é o único jeito de validar PDF neste repositório, que não tem
|
||||
# Ruby local. Uma constante com Rails.root no corpo do módulo mataria isso.
|
||||
def self.dir_padrao
|
||||
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
||||
Rails.root.join('app', 'assets', 'fonts').to_s
|
||||
else
|
||||
File.expand_path('../../assets/fonts', __dir__)
|
||||
end
|
||||
end
|
||||
|
||||
# Como os PDFs só usam normal e :bold, mapeamos italic/bold_italic para as
|
||||
# variantes existentes — assim um :italic pedido por engano não levanta erro.
|
||||
def registrar_fonte_utf8(documento, dir = Pdf::FonteUtf8.dir_padrao)
|
||||
documento.font_families.update(
|
||||
'DejaVu' => {
|
||||
normal: File.join(dir.to_s, 'DejaVuSans.ttf'),
|
||||
bold: File.join(dir.to_s, 'DejaVuSans-Bold.ttf'),
|
||||
italic: File.join(dir.to_s, 'DejaVuSans.ttf'),
|
||||
bold_italic: File.join(dir.to_s, 'DejaVuSans-Bold.ttf')
|
||||
}
|
||||
)
|
||||
documento.font 'DejaVu'
|
||||
end
|
||||
end
|
||||
end
|
||||
328
app/services/pdf/romaneio_pdf.rb
Normal file
328
app/services/pdf/romaneio_pdf.rb
Normal file
@@ -0,0 +1,328 @@
|
||||
# app/services/pdf/romaneio_pdf.rb
|
||||
#
|
||||
# "CONTROLE DE ENTREGA" — a folha que o motorista assina ao retirar a carga.
|
||||
# Uma página A4 PAISAGEM por veículo, no layout que a GADE já recebe hoje
|
||||
# (referência: Imagens para implantação/Romaneiro_EMAD_SETEMBRO_2026.pdf).
|
||||
#
|
||||
# NÃO herda de Pdf::BasePdf de propósito: aquela classe é retrato, chumba o
|
||||
# cabeçalho preto/laranja da REEM e instala um rodapé cinza repetido em todas as
|
||||
# páginas — os três errados aqui. Compartilham só Pdf::FonteUtf8.
|
||||
#
|
||||
# ── Por que desenho manual e não prawn-table ────────────────────────────────
|
||||
# A geometria aqui é RÍGIDA: altura de linha exata, sem quebra de texto, fonte que
|
||||
# encolhe por célula até caber. No prawn-table, `height` é PISO e não valor exato —
|
||||
# uma célula que quebrasse em duas linhas empurraria a tabela além de bounds.height
|
||||
# e o prawn-table dividiria em outra página, quebrando "uma folha por veículo" e
|
||||
# dessincronizando o cabeçalho (é exatamente o bug latente do programa Python que
|
||||
# originou esta tela: o contador de página avançava por PÁGINA, então um veículo de
|
||||
# duas páginas carimbava o veículo errado na segunda e em todas as seguintes).
|
||||
# O Prawn nativo resolve com `overflow: :shrink_to_fit, single_line: true` numa
|
||||
# caixa de altura fixa: depois que o corpo trava em MIN_FONTE o texto CORTA, em vez
|
||||
# de estourar a página.
|
||||
module Pdf
|
||||
class RomaneioPdf
|
||||
include Pdf::FonteUtf8
|
||||
|
||||
MM = 72 / 25.4
|
||||
|
||||
MARGEM = 10 * MM
|
||||
HEADER_H = 28 * MM # faixa do cabeçalho = margem superior do documento
|
||||
FOOTER_H = 22 * MM # bloco DECLARO + assinatura
|
||||
SPACER_H = 8 * MM # respiro entre a tabela e o DECLARO
|
||||
|
||||
# O ReportLab põe 6pt de topPadding no Frame do SimpleDocTemplate por padrão,
|
||||
# e o programa original nunca desconta isso. Medido contra o PDF de referência:
|
||||
# sem esses 6pt a tabela inteira sobe 9px (a 110 dpi) em relação ao documento
|
||||
# que a GADE conhece. O Prawn não tem esse padding, então ele entra à mão.
|
||||
PADDING_TOPO = 6
|
||||
|
||||
# Máximo de paradas por folha. Junto com as 2 linhas em branco e a linha de
|
||||
# cabeçalho, é o que faz a tabela ocupar exatamente uma página.
|
||||
MAX_LINHAS = 33
|
||||
LINHAS_BRANCO = 2
|
||||
TOTAL_LINHAS = MAX_LINHAS + LINHAS_BRANCO + 1
|
||||
|
||||
AZUL = '1B3A6B' # cabeçalho da tabela e títulos
|
||||
ZEBRA = 'EAF0FB' # fundo das linhas alternadas
|
||||
GRADE = 'AAAAAA'
|
||||
|
||||
# nil = "o resto da largura" (a coluna ENDEREÇO absorve a sobra)
|
||||
LARGURAS_MM = [8, 22, 50, nil, 22, 42].freeze
|
||||
CABECALHOS = ['#', 'NOTA FISCAL', 'NOME', 'ENDEREÇO', 'APARELHO', 'TELEFONES'].freeze
|
||||
CENTRALIZADAS = [0, 1, 4].freeze # #, NOTA FISCAL, APARELHO
|
||||
|
||||
MIN_FONTE = 5
|
||||
|
||||
# Espaçamentos do rodapé, CALIBRADOS contra o PDF de referência (render a 110 dpi
|
||||
# de Romaneiro_EMAD_SETEMBRO_2026.pdf, página 1). No original eles saem de três
|
||||
# tabelas do ReportLab com paddings próprios (TOPPADDING 4, 17 e 2 + o padding
|
||||
# padrão de 3 que ele não sobrescreve); reproduzir as tabelas só para chegar às
|
||||
# mesmas três linhas de texto não pagaria o custo — os números medidos, sim.
|
||||
RODAPE_TOPO = 5.3 # da base da tabela (após o SPACER_H) até o DECLARO
|
||||
RODAPE_ATE_ASSINATURA = 31.5 # do DECLARO até a linha de assinatura
|
||||
RODAPE_ATE_ROTULO = 17.2 # da linha de assinatura até "ASSINATURA DO MOTORISTA"
|
||||
RODAPE_ULTIMA_LINHA = 9 # altura da linha de "ASSINATURA DO MOTORISTA" (7,5pt)
|
||||
|
||||
DECLARACAO = 'DECLARO QUE RECEBI OS PRODUTOS PARA REALIZAR AS ENTREGAS NOS ENDEREÇOS ACIMA.'
|
||||
CAMPO_DATA = 'DATA: _______/_______/__________'
|
||||
CAMPO_MOT = 'MOTORISTA: ______________________________'
|
||||
|
||||
# `romaneio` só precisa responder a #rotulo_plano e #linhas_vigentes — assim o
|
||||
# arquivo carrega num container Ruby pelado (sem Rails) para a conferência de
|
||||
# layout em PNG, que é o único jeito de validar PDF neste repositório.
|
||||
# `veiculos: nil` = todos, na ordem do romaneio.
|
||||
def initialize(romaneio:, veiculos: nil, logo: nil)
|
||||
@romaneio = romaneio
|
||||
# Ruby puro (sem ActiveSupport) daqui para baixo: esta classe precisa
|
||||
# carregar num container sem Rails para a conferência de layout em PNG.
|
||||
lista = Array(veiculos).map(&:to_s).reject(&:empty?)
|
||||
@veiculos = lista.empty? ? nil : lista
|
||||
@logo = logo || self.class.logo_padrao
|
||||
|
||||
@pdf = Prawn::Document.new(page_size: 'A4', page_layout: :landscape,
|
||||
margin: [HEADER_H, MARGEM, MARGEM, MARGEM])
|
||||
registrar_fonte_utf8(@pdf)
|
||||
|
||||
altura_util = @pdf.bounds.height - SPACER_H - FOOTER_H
|
||||
@linha_h = altura_util / TOTAL_LINHAS
|
||||
@pad = [1.0, (@linha_h - 9) / 2].max
|
||||
@fonte_dados = [6.0, @linha_h - 2 * @pad - 2].max
|
||||
@fonte_hdr = [8.0, @fonte_dados + 0.5].min
|
||||
@larguras = calcular_larguras
|
||||
|
||||
# Quantas linhas (dados + branco) cabem numa folha SEM empurrar a última
|
||||
# linha do rodapé para fora da área útil.
|
||||
#
|
||||
# ⚠️ Não é decorativo: a conta do programa original (33 + 2 + 1 = 36 linhas)
|
||||
# ignora tanto o topPadding de 6pt do Frame do ReportLab quanto os 15% a mais
|
||||
# da linha de cabeçalho. Com 33 paradas de verdade o total estoura o orçamento
|
||||
# em ~8pt e o "ASSINATURA DO MOTORISTA" some da folha — sem erro nenhum, o
|
||||
# papel simplesmente sai sem o rótulo da assinatura. Medido aqui com 34
|
||||
# paradas; nunca disparou em produção porque o maior veículo real tem 31.
|
||||
altura_rodape = RODAPE_TOPO + RODAPE_ATE_ASSINATURA + RODAPE_ATE_ROTULO +
|
||||
RODAPE_ULTIMA_LINHA
|
||||
sobra = @pdf.bounds.height - SPACER_H - altura_rodape - PADDING_TOPO - @linha_h * 1.15
|
||||
@max_linhas_folha = (sobra / @linha_h).floor
|
||||
end
|
||||
|
||||
def self.logo_padrao
|
||||
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
||||
Rails.root.join('public', 'logo-gade.png').to_s
|
||||
else
|
||||
File.expand_path('../../../public/logo-gade.png', __dir__)
|
||||
end
|
||||
end
|
||||
|
||||
def render
|
||||
paginas = montar_paginas
|
||||
return @pdf.render if paginas.empty?
|
||||
|
||||
paginas.each_with_index do |pagina, i|
|
||||
@pdf.start_new_page unless i.zero?
|
||||
desenhar_pagina(pagina)
|
||||
end
|
||||
|
||||
@pdf.render
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# [{ veiculo:, folha:, total_folhas:, primeira_ordem:, linhas: [...] }, ...]
|
||||
#
|
||||
# Veículo com mais de MAX_LINHAS paradas vira N folhas. As alternativas eram
|
||||
# descartar a parada #34 (pacote sem papel nenhum) ou deixar vazar para a página
|
||||
# seguinte sob o cabeçalho do veículo ERRADO — corrupção silenciosa de um
|
||||
# documento assinado. Cada folha leva o próprio bloco de assinatura, e a
|
||||
# numeração continua entre folhas para bater com a ordem de parada no celular
|
||||
# do motorista.
|
||||
def montar_paginas
|
||||
agrupadas.flat_map do |veiculo, linhas|
|
||||
fatias = linhas.each_slice(MAX_LINHAS).to_a
|
||||
fatias.each_with_index.map do |fatia, i|
|
||||
{ veiculo: veiculo, folha: i + 1, total_folhas: fatias.size,
|
||||
primeira_ordem: i * MAX_LINHAS + 1, linhas: fatia }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def agrupadas
|
||||
linhas = @romaneio.linhas_vigentes.to_a
|
||||
linhas = linhas.select { |l| @veiculos.include?(l.veiculo.to_s) } if @veiculos
|
||||
linhas.group_by { |l| l.veiculo.to_s }
|
||||
end
|
||||
|
||||
def desenhar_pagina(pagina)
|
||||
desenhar_cabecalho(pagina)
|
||||
@pdf.move_down PADDING_TOPO
|
||||
desenhar_tabela(pagina)
|
||||
@pdf.move_down SPACER_H
|
||||
desenhar_rodape
|
||||
end
|
||||
|
||||
# ── Cabeçalho ────────────────────────────────────────────────────────────
|
||||
# Desenhado no `canvas` (coordenadas absolutas da página): a faixa vive DENTRO
|
||||
# da margem superior, acima da área de conteúdo.
|
||||
def desenhar_cabecalho(pagina)
|
||||
titulo = "CONTROLE DE ENTREGA — #{pagina[:veiculo]}"
|
||||
titulo += " (FOLHA #{pagina[:folha]}/#{pagina[:total_folhas]})" if pagina[:total_folhas] > 1
|
||||
base_y = @pdf.bounds.absolute_top + HEADER_H - MARGEM - 11 * MM
|
||||
|
||||
@pdf.canvas do
|
||||
if @logo && File.exist?(@logo.to_s)
|
||||
@pdf.image @logo, at: [MARGEM, @pdf.bounds.absolute_top - MARGEM],
|
||||
fit: [50 * MM, 17 * MM]
|
||||
end
|
||||
|
||||
# draw_text (e não text_box) porque o `at` dele é a BASELINE, igual ao
|
||||
# drawCentredString do ReportLab — text_box ancoraria pelo topo da caixa e
|
||||
# o título subiria ~4pt em relação ao documento que a GADE conhece.
|
||||
@pdf.fill_color AZUL
|
||||
largura_titulo = @pdf.width_of(titulo, size: 15, style: :bold)
|
||||
@pdf.draw_text titulo, size: 15, style: :bold,
|
||||
at: [(@pdf.bounds.absolute_right - largura_titulo) / 2.0, base_y]
|
||||
|
||||
desenhar_rotulo_plano(base_y)
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
end
|
||||
|
||||
# "PLANO:" em negrito azul + o rótulo em preto normal, ambos encostados na
|
||||
# direita. O rótulo é medido primeiro para o label parar exatamente antes dele.
|
||||
def desenhar_rotulo_plano(base_y)
|
||||
rotulo = @romaneio.rotulo_plano.to_s.strip
|
||||
return if rotulo.empty?
|
||||
|
||||
direita = @pdf.bounds.absolute_right - MARGEM
|
||||
largura = @pdf.width_of(rotulo, size: 9)
|
||||
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.draw_text rotulo, at: [direita - largura, base_y], size: 9
|
||||
@pdf.fill_color AZUL
|
||||
@pdf.draw_text 'PLANO: ', at: [direita - largura - @pdf.width_of('PLANO: ', size: 9, style: :bold),
|
||||
base_y], size: 9, style: :bold
|
||||
end
|
||||
|
||||
# ── Tabela ───────────────────────────────────────────────────────────────
|
||||
def desenhar_tabela(pagina)
|
||||
topo = @pdf.cursor
|
||||
alt_h = @linha_h * 1.15
|
||||
y = topo
|
||||
|
||||
desenhar_linha_cabecalho(y, alt_h)
|
||||
y -= alt_h
|
||||
|
||||
pagina[:linhas].each_with_index do |linha, i|
|
||||
# No original o zebrado começa em EAF0FB: a primeira linha de DADOS é a
|
||||
# colorida. Inverter isso troca a paridade da tabela inteira.
|
||||
cor = i.even? ? ZEBRA : 'FFFFFF'
|
||||
desenhar_linha(y, cor, celulas(linha, pagina[:primeira_ordem] + i))
|
||||
y -= @linha_h
|
||||
end
|
||||
|
||||
# As linhas em branco existem para o operador anotar uma parada à mão. Numa
|
||||
# folha cheia não há espaço para elas — e insistir empurraria o rodapé para
|
||||
# fora do papel. Então elas entram até onde couberem.
|
||||
brancas = [LINHAS_BRANCO, @max_linhas_folha - pagina[:linhas].size].min
|
||||
brancas = 0 if brancas.negative?
|
||||
|
||||
brancas.times do |i|
|
||||
cor = (pagina[:linhas].size + i).even? ? ZEBRA : 'FFFFFF'
|
||||
desenhar_linha(y, cor, Array.new(6, ''))
|
||||
y -= @linha_h
|
||||
end
|
||||
|
||||
desenhar_grade(topo, y, alt_h, pagina[:linhas].size + brancas)
|
||||
@pdf.move_cursor_to y
|
||||
end
|
||||
|
||||
def celulas(linha, numero)
|
||||
[numero.to_s, linha.nota_fiscal.to_s, linha.nome.to_s,
|
||||
linha.endereco.to_s, linha.aparelho.to_s, linha.telefones.to_s]
|
||||
end
|
||||
|
||||
def desenhar_linha_cabecalho(y, altura)
|
||||
@pdf.fill_color AZUL
|
||||
@pdf.fill_rectangle [0, y], @pdf.bounds.width, altura
|
||||
@pdf.fill_color 'FFFFFF'
|
||||
escrever_celulas(y, altura, CABECALHOS, size: @fonte_hdr, style: :bold, tudo_centro: true)
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
|
||||
def desenhar_linha(y, cor, valores)
|
||||
@pdf.fill_color cor
|
||||
@pdf.fill_rectangle [0, y], @pdf.bounds.width, @linha_h
|
||||
@pdf.fill_color '000000'
|
||||
escrever_celulas(y, @linha_h, valores, size: @fonte_dados)
|
||||
end
|
||||
|
||||
def escrever_celulas(y, altura, valores, size:, style: :normal, tudo_centro: false)
|
||||
x = 0
|
||||
valores.each_with_index do |valor, i|
|
||||
largura = @larguras[i]
|
||||
texto = valor.to_s
|
||||
unless texto.empty?
|
||||
alinhamento = tudo_centro || CENTRALIZADAS.include?(i) ? :center : :left
|
||||
@pdf.text_box texto, at: [x + 3, y], width: largura - 6, height: altura,
|
||||
size: size, style: style, valign: :center,
|
||||
align: alinhamento, overflow: :shrink_to_fit,
|
||||
min_font_size: MIN_FONTE, single_line: true
|
||||
end
|
||||
x += largura
|
||||
end
|
||||
end
|
||||
|
||||
def desenhar_grade(topo, base, altura_cabecalho, total_linhas)
|
||||
@pdf.line_width 0.4
|
||||
@pdf.stroke_color GRADE
|
||||
|
||||
y = topo - altura_cabecalho
|
||||
(total_linhas + 1).times do
|
||||
@pdf.stroke_horizontal_line 0, @pdf.bounds.width, at: y
|
||||
y -= @linha_h
|
||||
end
|
||||
|
||||
x = 0
|
||||
@larguras.each do |largura|
|
||||
@pdf.stroke_vertical_line base, topo, at: x
|
||||
x += largura
|
||||
end
|
||||
@pdf.stroke_vertical_line base, topo, at: @pdf.bounds.width
|
||||
|
||||
@pdf.line_width 0.8
|
||||
@pdf.stroke_color '000000'
|
||||
@pdf.stroke_rectangle [0, topo], @pdf.bounds.width, topo - base
|
||||
@pdf.stroke_color '000000'
|
||||
@pdf.line_width 1
|
||||
end
|
||||
|
||||
# ── Rodapé ───────────────────────────────────────────────────────────────
|
||||
# O nome do motorista NÃO entra: a linha "MOTORISTA: ____" é preenchida à mão
|
||||
# na retirada — é assim que o documento é assinado hoje.
|
||||
def desenhar_rodape
|
||||
col_l = @pdf.bounds.width * 0.62
|
||||
col_r = @pdf.bounds.width * 0.38
|
||||
|
||||
y = @pdf.cursor - RODAPE_TOPO
|
||||
@pdf.text_box DECLARACAO, at: [0, y], width: col_l, size: 7.5, style: :bold,
|
||||
overflow: :shrink_to_fit, single_line: true
|
||||
@pdf.text_box CAMPO_DATA, at: [col_l, y], width: col_r, size: 8, style: :bold,
|
||||
align: :right, single_line: true
|
||||
|
||||
y -= RODAPE_ATE_ASSINATURA
|
||||
@pdf.text_box '_' * 55, at: [0, y], width: col_l, size: 8, single_line: true
|
||||
@pdf.text_box CAMPO_MOT, at: [col_l, y], width: col_r, size: 8, style: :bold,
|
||||
align: :right, single_line: true
|
||||
|
||||
y -= RODAPE_ATE_ROTULO
|
||||
@pdf.text_box 'ASSINATURA DO MOTORISTA', at: [0, y], width: col_l,
|
||||
size: 7.5, style: :bold, single_line: true
|
||||
end
|
||||
|
||||
# A coluna nil (ENDEREÇO) recebe o que sobrar da largura útil.
|
||||
def calcular_larguras
|
||||
fixas = LARGURAS_MM.compact.sum * MM
|
||||
resto = @pdf.bounds.width - fixas
|
||||
LARGURAS_MM.map { |mm| mm.nil? ? resto : mm * MM }
|
||||
end
|
||||
end
|
||||
end
|
||||
101
app/services/romaneios/enriquecimento_gade.rb
Normal file
101
app/services/romaneios/enriquecimento_gade.rb
Normal 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
|
||||
215
app/services/romaneios/importador.rb
Normal file
215
app/services/romaneios/importador.rb
Normal 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
|
||||
87
app/services/romaneios/normalizador.rb
Normal file
87
app/services/romaneios/normalizador.rb
Normal 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
|
||||
153
app/services/romaneios/plano_do_dia.rb
Normal file
153
app/services/romaneios/plano_do_dia.rb
Normal 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
|
||||
140
app/services/romaneios/plano_planilha.rb
Normal file
140
app/services/romaneios/plano_planilha.rb
Normal 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
|
||||
@@ -64,6 +64,22 @@ module SimpliRoute
|
||||
Array(get(caminho))
|
||||
end
|
||||
|
||||
# Rotas PLANEJADAS da data (Array de hashes). É aqui que costuma morar o par
|
||||
# veículo/motorista, que a visita talvez não carregue — e o romaneio precisa do
|
||||
# veículo para saber quantas folhas imprimir.
|
||||
#
|
||||
# ⚠️ NÃO VERIFICADO contra a API real: não há token na máquina de
|
||||
# desenvolvimento. Por isso é best-effort (endpoint inexistente devolve [], não
|
||||
# explode) e por isso Romaneios::PlanoDoDia tem uma escada de queda até o
|
||||
# espelho local. Rode `bin/sondar_plano_do_dia` no servidor antes de confiar
|
||||
# nesta chamada — e anote o resultado no cabeçalho daquele script, como foi
|
||||
# feito em bin/sondar_busca_nf.
|
||||
def rotas_da_data(data)
|
||||
Array(get("/v1/routes/routes/?planned_date=#{data.to_date.iso8601}"))
|
||||
rescue Error
|
||||
[]
|
||||
end
|
||||
|
||||
# Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega
|
||||
# do espelho local, que só tem tracking_id (SR...) + reference_id (NF) +
|
||||
# planned_date. Casa pelo tracking_id; se não achar, tenta pela NF.
|
||||
|
||||
Reference in New Issue
Block a user