Fases 7 e 8 — PDFs Prawn + QR + Painel Motorista + Notificações
This commit is contained in:
68
app/services/notificacao_service.rb
Normal file
68
app/services/notificacao_service.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
# app/services/notificacao_service.rb
|
||||
#
|
||||
# Envia notificações aos motoristas quando uma consolidação é finalizada.
|
||||
# Canais controlados pelas configurações: notificacao_whatsapp / notificacao_email.
|
||||
# Falhas de envio NUNCA quebram o fluxo de finalização (apenas logam).
|
||||
#
|
||||
class NotificacaoService
|
||||
def self.notificar_finalizacao(consolidacao)
|
||||
new(consolidacao).notificar_todos
|
||||
end
|
||||
|
||||
def initialize(consolidacao)
|
||||
@consolidacao = consolidacao
|
||||
end
|
||||
|
||||
def notificar_todos
|
||||
@consolidacao.consolidacao_motoristas.each do |cm|
|
||||
user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
|
||||
next unless user
|
||||
|
||||
enviar_whatsapp(user, cm) if whatsapp_ativo?
|
||||
enviar_email(user, cm) if email_ativo? && user.email.present?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def whatsapp_ativo?
|
||||
Configuracao.valor('notificacao_whatsapp') == 'true' &&
|
||||
ENV['TWILIO_ACCOUNT_SID'].present?
|
||||
end
|
||||
|
||||
def email_ativo?
|
||||
Configuracao.valor('notificacao_email') == 'true'
|
||||
end
|
||||
|
||||
def mensagem(user, cm)
|
||||
valor = "R$ #{format('%.2f', cm.valor_total)}".gsub('.', ',')
|
||||
"Olá #{user.nome.split.first}! 🚚\n" \
|
||||
"Seu pagamento de entregas foi fechado.\n" \
|
||||
"📋 #{@consolidacao.nome}\n" \
|
||||
"💰 Valor: #{valor}\n" \
|
||||
"Acesse seu painel para baixar o holerite: " \
|
||||
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
end
|
||||
|
||||
def enviar_whatsapp(user, cm)
|
||||
return if user.telefone.blank?
|
||||
|
||||
require 'twilio-ruby'
|
||||
cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN'])
|
||||
cliente.messages.create(
|
||||
from: ENV['TWILIO_WHATSAPP_FROM'],
|
||||
to: "whatsapp:#{user.telefone}",
|
||||
body: mensagem(user, cm)
|
||||
)
|
||||
Rails.logger.info("[Notificacao] WhatsApp enviado para #{user.nome}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Notificacao] Falha WhatsApp #{user.nome}: #{e.message}")
|
||||
end
|
||||
|
||||
def enviar_email(user, cm)
|
||||
ConsolidacaoMailer.pagamento_fechado(user, @consolidacao, cm).deliver_later
|
||||
Rails.logger.info("[Notificacao] Email agendado para #{user.email}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Notificacao] Falha email #{user.email}: #{e.message}")
|
||||
end
|
||||
end
|
||||
68
app/services/pdf/base_pdf.rb
Normal file
68
app/services/pdf/base_pdf.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
# app/services/pdf/base_pdf.rb
|
||||
#
|
||||
# Layout base dos PDFs da Gade: cabeçalho preto com logo laranja,
|
||||
# rodapé com data de geração. Herdar e implementar #corpo.
|
||||
#
|
||||
module Pdf
|
||||
class BasePdf
|
||||
LARANJA = 'F97316'
|
||||
PRETO = '0A0A0A'
|
||||
CINZA = '6B7280'
|
||||
|
||||
def initialize
|
||||
@pdf = Prawn::Document.new(page_size: 'A4', margin: [40, 40, 60, 40])
|
||||
end
|
||||
|
||||
def render
|
||||
cabecalho
|
||||
corpo
|
||||
rodape
|
||||
@pdf.render
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cabecalho
|
||||
@pdf.fill_color PRETO
|
||||
@pdf.fill_rectangle [-40, @pdf.cursor + 40], @pdf.bounds.width + 80, 70
|
||||
|
||||
@pdf.fill_color LARANJA
|
||||
@pdf.fill_rectangle [-40 + 0, @pdf.cursor + 40], 8, 70 # faixa laranja lateral
|
||||
|
||||
@pdf.fill_color 'FFFFFF'
|
||||
@pdf.text_box 'GADE HOSPITALAR',
|
||||
at: [10, @pdf.cursor + 20], size: 20, style: :bold
|
||||
@pdf.fill_color LARANJA
|
||||
@pdf.text_box 'Sistema de Controle de Custos Logísticos',
|
||||
at: [10, @pdf.cursor - 4], size: 10
|
||||
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.move_down 55
|
||||
end
|
||||
|
||||
def rodape
|
||||
@pdf.repeat(:all) do
|
||||
@pdf.bounding_box [0, 20], width: @pdf.bounds.width do
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text "Gerado em #{Time.current.strftime('%d/%m/%Y %H:%M')} · Gade Hospitalar — documento interno",
|
||||
size: 8, align: :center
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def moeda(v)
|
||||
"R$ #{format('%.2f', v.to_f)}".gsub('.', ',')
|
||||
end
|
||||
|
||||
def secao(titulo)
|
||||
@pdf.move_down 14
|
||||
@pdf.fill_color LARANJA
|
||||
@pdf.text titulo, size: 13, style: :bold
|
||||
@pdf.stroke_color LARANJA
|
||||
@pdf.stroke_horizontal_rule
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.move_down 8
|
||||
end
|
||||
end
|
||||
end
|
||||
97
app/services/pdf/holerite_pdf.rb
Normal file
97
app/services/pdf/holerite_pdf.rb
Normal file
@@ -0,0 +1,97 @@
|
||||
# app/services/pdf/holerite_pdf.rb
|
||||
#
|
||||
# Holerite: resumo do valor a pagar ao motorista, estilo contracheque,
|
||||
# com QR Code para login rápido no painel do motorista.
|
||||
#
|
||||
require 'rqrcode'
|
||||
|
||||
module Pdf
|
||||
class HoleritePdf < BasePdf
|
||||
def initialize(consolidacao:, motorista:, user_motorista: nil, app_host: nil)
|
||||
super()
|
||||
@consolidacao = consolidacao
|
||||
@motorista = motorista
|
||||
@user_motorista = user_motorista
|
||||
@app_host = app_host || ENV.fetch('APP_HOST', 'localhost:3000')
|
||||
@entregas = consolidacao.consolidacao_entregas.where(motorista_nome: motorista)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def corpo
|
||||
secao "HOLERITE DE PAGAMENTO — ENTREGAS"
|
||||
|
||||
# Bloco de identificação (estilo contracheque)
|
||||
info = [
|
||||
['Motorista', @motorista],
|
||||
['Consolidação', @consolidacao.nome],
|
||||
['Período', "#{@consolidacao.data_inicio.strftime('%d/%m/%Y')} a #{@consolidacao.data_fim.strftime('%d/%m/%Y')}"],
|
||||
['Emissão', Time.current.strftime('%d/%m/%Y')]
|
||||
]
|
||||
@pdf.table(info, width: @pdf.bounds.width, cell_style: { size: 10, padding: [5, 8] }) do |t|
|
||||
t.columns(0).font_style = :bold
|
||||
t.columns(0).background_color = 'F3F4F6'
|
||||
t.columns(0).width = 120
|
||||
end
|
||||
|
||||
secao "PROVENTOS E DESCONTOS"
|
||||
|
||||
resumo = @entregas.group(:tipo).count
|
||||
por_tipo = ConsolidacaoEntrega::TIPO_CORES.map do |tipo, cfg|
|
||||
qtd = resumo[tipo] || 0
|
||||
next if qtd.zero?
|
||||
valor = @entregas.where(tipo: tipo).sum(:valor_aplicado)
|
||||
sinal = tipo == 'desconto' ? '-' : '+'
|
||||
[cfg[:label], qtd.to_s, "#{sinal} #{moeda(valor)}"]
|
||||
end.compact
|
||||
|
||||
linhas = [['Descrição', 'Qtd', 'Valor']] + por_tipo
|
||||
@pdf.table(linhas, header: true, width: @pdf.bounds.width,
|
||||
cell_style: { size: 10, padding: [6, 8] }) do |t|
|
||||
t.row(0).background_color = PRETO
|
||||
t.row(0).text_color = 'FFFFFF'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(1..2).align = :right
|
||||
end
|
||||
|
||||
total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado }
|
||||
|
||||
# Caixa do total — destaque laranja
|
||||
@pdf.move_down 16
|
||||
@pdf.fill_color LARANJA
|
||||
@pdf.fill_rounded_rectangle [0, @pdf.cursor], @pdf.bounds.width, 50, 8
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.text_box 'VALOR LÍQUIDO A RECEBER',
|
||||
at: [16, @pdf.cursor - 10], size: 10, style: :bold
|
||||
@pdf.text_box moeda(total),
|
||||
at: [16, @pdf.cursor - 24], size: 22, style: :bold
|
||||
@pdf.move_down 64
|
||||
|
||||
# QR Code de login rápido
|
||||
desenhar_qrcode if @user_motorista&.login_token.present?
|
||||
end
|
||||
|
||||
def desenhar_qrcode
|
||||
url = "https://#{@app_host}/motorista/acesso/#{@user_motorista.login_token}"
|
||||
qr = RQRCode::QRCode.new(url)
|
||||
png = qr.as_png(size: 220, border_modules: 2)
|
||||
|
||||
arquivo = Tempfile.new(['qr', '.png'])
|
||||
arquivo.binmode
|
||||
arquivo.write(png.to_s)
|
||||
arquivo.rewind
|
||||
|
||||
secao "ACESSO RÁPIDO AO SEU PAINEL"
|
||||
@pdf.image arquivo.path, width: 110, position: :left
|
||||
@pdf.move_up 95
|
||||
@pdf.text_box "Escaneie este QR Code com a câmera do celular para entrar\n" \
|
||||
"no seu painel e acompanhar seus pagamentos.\n\n" \
|
||||
"Ou acesse: #{@app_host}/motorista\ne use seu PIN de 4 dígitos.",
|
||||
at: [130, @pdf.cursor], width: @pdf.bounds.width - 130, size: 9
|
||||
@pdf.move_down 100
|
||||
ensure
|
||||
arquivo&.close
|
||||
arquivo&.unlink
|
||||
end
|
||||
end
|
||||
end
|
||||
77
app/services/pdf/relatorio_motorista_pdf.rb
Normal file
77
app/services/pdf/relatorio_motorista_pdf.rb
Normal file
@@ -0,0 +1,77 @@
|
||||
# app/services/pdf/relatorio_motorista_pdf.rb
|
||||
#
|
||||
# Relatório Individual: detalhe de todas as entregas classificadas
|
||||
# de um motorista em uma consolidação.
|
||||
#
|
||||
module Pdf
|
||||
class RelatorioMotoristaPdf < BasePdf
|
||||
def initialize(consolidacao:, motorista:)
|
||||
super()
|
||||
@consolidacao = consolidacao
|
||||
@motorista = motorista
|
||||
@entregas = consolidacao.consolidacao_entregas
|
||||
.where(motorista_nome: motorista)
|
||||
.order(:created_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def corpo
|
||||
secao "RELATÓRIO INDIVIDUAL DE ENTREGAS"
|
||||
|
||||
@pdf.text "Consolidação: #{@consolidacao.nome}", size: 11, style: :bold
|
||||
@pdf.text "Motorista: #{@motorista}", size: 11
|
||||
@pdf.text "Período: #{@consolidacao.data_inicio.strftime('%d/%m/%Y')} a #{@consolidacao.data_fim.strftime('%d/%m/%Y')}", size: 10
|
||||
@pdf.text "Status: #{@consolidacao.status.humanize}", size: 10, color: CINZA
|
||||
|
||||
secao "ENTREGAS (#{@entregas.size})"
|
||||
|
||||
if @entregas.any?
|
||||
linhas = [['#', 'Tracking', 'NF / Local', 'Tipo', 'Valor']]
|
||||
@entregas.each_with_index do |e, i|
|
||||
original = e.entrega_original
|
||||
linhas << [
|
||||
(i + 1).to_s,
|
||||
e.tracking_id.to_s.first(14),
|
||||
original ? "NF #{original.numero_nf} · #{original.local.to_s.first(30)}" : '—',
|
||||
e.tipo_cor[:label],
|
||||
(e.desconto? ? '-' : '') + moeda(e.valor_aplicado)
|
||||
]
|
||||
end
|
||||
|
||||
@pdf.table(linhas, header: true, width: @pdf.bounds.width,
|
||||
cell_style: { size: 8, padding: [4, 6] }) do |t|
|
||||
t.row(0).background_color = PRETO
|
||||
t.row(0).text_color = 'FFFFFF'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(4).align = :right
|
||||
t.row(1..-1).borders = [:bottom]
|
||||
t.row(1..-1).border_color = 'DDDDDD'
|
||||
end
|
||||
else
|
||||
@pdf.text 'Nenhuma entrega classificada.', size: 10, color: CINZA
|
||||
end
|
||||
|
||||
# Totais por tipo
|
||||
secao "RESUMO"
|
||||
resumo = @entregas.group(:tipo).count
|
||||
total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado }
|
||||
|
||||
resumo_linhas = [['Tipo', 'Quantidade']]
|
||||
ConsolidacaoEntrega::TIPO_CORES.each do |tipo, cfg|
|
||||
resumo_linhas << [cfg[:label], (resumo[tipo] || 0).to_s]
|
||||
end
|
||||
|
||||
@pdf.table(resumo_linhas, width: 250, cell_style: { size: 9, padding: [4, 8] }) do |t|
|
||||
t.row(0).background_color = LARANJA
|
||||
t.row(0).text_color = '000000'
|
||||
t.row(0).font_style = :bold
|
||||
end
|
||||
|
||||
@pdf.move_down 14
|
||||
@pdf.fill_color LARANJA
|
||||
@pdf.text "TOTAL A PAGAR: #{moeda(total)}", size: 16, style: :bold
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user