Files
Reem-Notas/app/services/pdf/romaneio_pdf.rb

385 lines
17 KiB
Ruby

# 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
# O logo é ESCOLHÍVEL na tela (Configuracao 'romaneio_logo') porque o romaneio é
# documento do CLIENTE: quando a operação muda de contratante, trocar o
# cabeçalho não pode exigir deploy. Sem escolha, cai no logo da GADE que está
# no repositório.
def self.logo_padrao
return File.expand_path('../../../public/logo-gade.png', __dir__) unless dentro_do_rails?
logo_escolhido || Rails.root.join('public', 'logo-gade.png').to_s
end
# Pasta dos logos enviados pela tela. Fora de public/ de propósito: o que o
# operador sobe não vira arquivo servido pela web sem passar por autorização.
def self.pasta_logos
Rails.root.join('storage', 'logos')
end
def self.dentro_do_rails?
defined?(Rails) && Rails.respond_to?(:root) && !Rails.root.nil?
end
def self.logo_escolhido
caminho, = logo_configurado
caminho
end
# O logo escolhido E o motivo de ele não estar valendo — os dois juntos,
# porque quem chama precisa saber a diferença entre "ninguém escolheu logo"
# e "o logo que o operador subiu SUMIU do disco".
#
# ⚠️ ISTO ERA UM `File.exist?(x) ? x : nil` MUDO, e foi um bug real
# (relatado em 28/08/2026): o operador subiu outro logo, a tela disse "Logo
# atualizado", e o PDF continuou saindo com o logo antigo — porque o arquivo
# não estava mais na pasta e o método caía no padrão sem dizer nada a
# ninguém. Falha silenciosa em cima de documento que vai IMPRESSO para o
# cliente é o pior tipo: ninguém procura o que não reclama.
#
# A causa mais provável do arquivo sumir: `storage/` é gitignorado
# (`/storage/*`) e o deploy é cópia de pasta pelo share SMB do NAS — a cópia
# leva junto a pasta vazia da máquina de quem publicou. Se acontecer de
# novo, o log e a tela agora dizem.
#
# Devolve [caminho, problema]:
# [nil, nil] -> ninguém escolheu logo (usa o da GADE, normal)
# [caminho, nil] -> logo escolhido e presente
# [nil, :sumiu] -> escolhido MAS o arquivo não está lá
# [nil, :sem_banco] -> sem Rails/tabela (o container pelado do layout)
def self.logo_configurado
nome = Configuracao.valor('romaneio_logo').to_s
return [nil, nil] if nome.strip.empty?
caminho = pasta_logos.join(nome).to_s
return [caminho, nil] if File.exist?(caminho)
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
Rails.logger.error("[romaneio] logo '#{nome}' configurado mas AUSENTE em #{pasta_logos}" \
'o PDF saiu com o logo padrão')
end
[nil, :sumiu]
rescue StandardError
# Sem banco (a conferência de layout roda num container Ruby pelado) ou
# tabela ainda não migrada: o PDF continua saindo, com o logo padrão.
[nil, :sem_banco]
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