# 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