329 lines
14 KiB
Ruby
329 lines
14 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
|
|
|
|
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
|