Correção de horario e implantação da geração de Romaneio

This commit is contained in:
2026-08-27 16:21:56 -03:00
parent a0aafe62fc
commit ad1a071e4b
51 changed files with 3450 additions and 46 deletions

View File

@@ -0,0 +1,232 @@
# app/controllers/admin/romaneios_controller.rb
#
# Romaneio "CONTROLE DE ENTREGA": importa o plano do dia, deixa o operador revisar a
# tabela com a prévia do PDF real ao lado, e imprime uma folha A4 paisagem por
# veículo.
#
# As bases externas (espelho de rastreio e gade_entregas_*) continuam SOMENTE
# LEITURA — o que gravamos são as tabelas `romaneio*`, nossas.
class Admin::RomaneiosController < ApplicationController
include Auditavel
before_action :carregar_romaneio, except: %i[index create importar_planilha]
def index
authorize Romaneio
# includes: a listagem mostra a contagem de veículos de cada romaneio.
@romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50)
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@data_padrao = Date.current
end
def show
authorize @romaneio
@veiculos = @romaneio.romaneio_veiculos.ordenados.to_a
@veiculo = veiculo_selecionado
@linhas = @romaneio.linhas_vigentes.do_veiculo(@veiculo).to_a if @veiculo
@removidas = @romaneio.linhas_removidas.to_a
@colunas_ausentes = colunas_ausentes_da_operacao
end
# Caminho A: busca o plano no SimpliRoute (API, com queda para o espelho).
def create
authorize Romaneio
@romaneio = preparar_romaneio
return if performed?
plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date)
importar(plano.linhas, origem: plano.origem)
rescue SimpliRoute::Error => e
# NotFound herda de Error: a mensagem dele já diz para enviar o .xlsx.
descartar_romaneio_vazio
redirect_to admin_romaneios_path, alert: e.message
end
# Caminho B: upload do .xlsx do plano, para quando o plano do dia ainda não foi
# publicado ou a API não responde.
def importar_planilha
authorize Romaneio
@romaneio = preparar_romaneio
return if performed?
arquivo = params[:arquivo]
if arquivo.blank?
descartar_romaneio_vazio
return redirect_to(admin_romaneios_path, alert: 'Selecione a planilha do plano (.xlsx).')
end
importar(Romaneios::PlanoPlanilha.new(arquivo).linhas, origem: 'planilha')
rescue Romaneios::PlanoPlanilha::Erro => e
descartar_romaneio_vazio
redirect_to admin_romaneios_path, alert: e.message
end
# Rebusca o plano e reconcilia com o que já está editado (a regra está em
# Romaneios::Importador).
def reimportar
authorize @romaneio
# Reimportar com arquivo em mãos usa o arquivo; sem arquivo, volta ao
# SimpliRoute — inclusive para um romaneio que nasceu de planilha (é justamente
# assim que o operador troca o plano B pelo A quando a API volta).
if params[:arquivo].present?
plano = Romaneios::PlanoPlanilha.new(params[:arquivo])
linhas = plano.linhas
origem = 'planilha'
else
plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date)
linhas = plano.linhas
origem = plano.origem
end
resultado = aplicar(linhas, origem: origem)
auditar!(:reimportar, @romaneio, dados_novos: resultado.to_h)
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
notice: "Plano reimportado — #{resultado.resumo}."
rescue SimpliRoute::Error, Romaneios::PlanoPlanilha::Erro => e
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), alert: e.message
end
# Com ?veiculo= sai INLINE (é a prévia do editor, uma folha só, sub-100ms);
# sem veiculo sai o romaneio inteiro como download.
def pdf
authorize @romaneio
veiculo = params[:veiculo].to_s
binario = Pdf::RomaneioPdf.new(romaneio: @romaneio,
veiculos: veiculo.presence && [veiculo]).render
# A prévia é regerada a cada edição: cache do navegador aqui devolveria o PDF
# anterior e o operador acharia que a alteração não gravou.
expires_now if veiculo.present?
send_data binario, type: 'application/pdf',
disposition: veiculo.present? ? 'inline' : 'attachment',
filename: nome_do_arquivo(veiculo)
end
# JSON — grava UM campo de UMA parada. `valor: null` restaura o que o plano
# trouxe (e destrava o campo para a próxima reimportação).
def atualizar_linha
authorize @romaneio
linha = @romaneio.romaneio_linhas.find(params[:linha_id])
campo = params[:campo].to_s
unless RomaneioLinha::CAMPOS_EDITAVEIS.include?(campo)
return render json: { ok: false, erro: 'Campo não editável.' }, status: :unprocessable_entity
end
if params[:valor].nil?
linha[campo] = linha.valor_do_plano(campo)
linha.desmarcar_editado!(campo)
else
linha[campo] = params[:valor].to_s
linha.marcar_editado!(campo)
end
linha.save!
# Devolvemos o valor GRAVADO e o JS o reescreve no input: a gravação é
# last-write-wins, e assim uma sobreposição de duas abas fica visível em vez
# de silenciosa.
render json: { ok: true, valor: linha[campo], editado: linha.editado?(campo) }
end
def atualizar_veiculo
authorize @romaneio
registro = @romaneio.romaneio_veiculos.find_by!(veiculo: params[:veiculo].to_s)
registro.update!(motorista: params[:motorista].to_s)
render json: { ok: true, valor: registro.motorista }
end
def atualizar_rotulo
authorize @romaneio
@romaneio.update!(rotulo_plano: params[:rotulo_plano].to_s)
render json: { ok: true, valor: @romaneio.rotulo_plano }
end
def destroy
authorize @romaneio
# Auditar ANTES: depois do destroy não há mais atributos para registrar.
auditar!(:excluir, @romaneio, dados_anteriores: @romaneio.attributes)
@romaneio.destroy!
redirect_to admin_romaneios_path, notice: 'Romaneio excluído.'
end
private
def carregar_romaneio
@romaneio = Romaneio.find(params[:id])
end
# Quais colunas a operação NÃO tem. Perguntamos ao serviço em vez de deduzir de
# "a coluna APARELHO saiu toda vazia": vazio também é o resultado legítimo de uma
# operação só com pacientes recorrentes, e um aviso que erra o motivo é pior do
# que nenhum.
def colunas_ausentes_da_operacao
return [] if @romaneio.operacao_tabela.blank?
Romaneios::EnriquecimentoGade.new(@romaneio.operacao_tabela).colunas_ausentes
rescue ArgumentError
# Operação removida do banco depois que o romaneio foi criado.
Romaneios::EnriquecimentoGade::COLUNAS.keys
end
# Acha (ou cria) o romaneio da data + operação. `find_or_create_by!` é o que faz
# "importar de novo o mesmo dia" cair na reconciliação em vez de criar um segundo
# romaneio — a chave única (planned_date, operacao_tabela) é a garantia disso.
def preparar_romaneio
data = params[:planned_date].presence
if data.blank?
redirect_to admin_romaneios_path, alert: 'Informe a data do plano.'
return nil
end
tabela = params[:operacao_tabela].to_s
if tabela.present? && !Operacao.valida?(tabela)
redirect_to admin_romaneios_path, alert: 'Selecione uma operação válida.'
return nil
end
data = Date.parse(data)
Romaneio.find_or_create_by!(planned_date: data, operacao_tabela: tabela) do |r|
r.rotulo_plano = params[:rotulo_plano].presence || Romaneio.rotulo_sugerido(tabela, data)
r.importado_por = current_user
end
rescue Date::Error
redirect_to admin_romaneios_path, alert: 'Data do plano inválida.'
nil
end
def importar(linhas, origem:)
resultado = aplicar(linhas, origem: origem)
auditar!(:criar, @romaneio, dados_novos: resultado.to_h)
redirect_to admin_romaneio_path(@romaneio),
notice: "Plano importado — #{resultado.resumo}."
end
def aplicar(linhas, origem:)
Romaneios::Importador.new(romaneio: @romaneio, linhas: linhas,
usuario: current_user, origem: origem).aplicar!
end
# Um romaneio recém-criado que não chegou a receber nenhuma parada é lixo: some
# da lista para o operador não achar que a importação funcionou.
def descartar_romaneio_vazio
@romaneio&.destroy! if @romaneio&.persisted? && @romaneio.romaneio_linhas.none?
end
def veiculo_selecionado
pedido = params[:veiculo].to_s
return pedido if @veiculos.any? { |v| v.veiculo == pedido }
@veiculos.first&.veiculo
end
def nome_do_arquivo(veiculo)
partes = ['romaneio', @romaneio.rotulo_plano.presence || @romaneio.planned_date.to_s, veiculo]
"#{partes.compact_blank.join('_').parameterize(separator: '_')}.pdf"
end
end

View File

@@ -0,0 +1,163 @@
import { Controller } from "@hotwired/stimulus"
// Editor do romaneio: salva cada campo assim que ele muda e reatualiza a prévia
// (que é o PDF de verdade, num iframe).
//
// Por que salvar POR CAMPO e não com um submit de formulário: `campos_editados`
// precisa registrar exatamente o campo que um humano tocou. Um submit em bloco não
// distingue "redigitei o mesmo valor" de "nunca olhei" — marcaria os 5 campos das 29
// linhas como editados, e a reimportação seguinte preservaria endereços velhos no
// plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com
// escrita por campo.
export default class extends Controller {
static targets = ["celula", "preview", "veiculo", "motorista", "rotulo", "status", "linha"]
static values = { url: String, pdfUrl: String, veiculo: String }
// Recarregar o PDF a cada tecla travaria a tela; 600ms depois do último save é o
// suficiente para parecer imediato sem regerar o documento à toa.
static ATRASO_PREVIEW = 600
connect() {
this.timerPreview = null
}
disconnect() {
if (this.timerPreview) clearTimeout(this.timerPreview)
}
// ── Edição ──────────────────────────────────────────────────────────────
salvarCelula(event) {
const input = event.currentTarget
// Sair do campo sem mudar nada não é edição — marcar como editado aqui
// congelaria o valor contra a próxima reimportação sem o operador querer.
if (input.value === input.dataset.original) return
this.gravar(input.dataset.linhaId, input.dataset.campo, input.value, input)
}
restaurar(event) {
const botao = event.currentTarget
const { linhaId, campo } = botao.dataset
const input = this.celulaTargets.find(
(c) => c.dataset.linhaId === linhaId && c.dataset.campo === campo
)
// valor null no corpo = "devolve o que o plano trouxe e destrava o campo"
this.gravar(linhaId, campo, null, input, botao)
}
alternarAparelho(event) {
const botao = event.currentTarget
const novo = botao.dataset.valor === "SIM" ? "" : "SIM"
this.gravar(botao.dataset.linhaId, "aparelho", novo, null, null, (dados) => {
botao.dataset.valor = dados.valor
botao.textContent = dados.valor || "—"
const estilo = dados.valor === "SIM"
? "bg-green-600/20 text-green-300 border-green-600/40"
: "bg-[#1a1a1a] text-gray-500 border-white/10"
botao.className = `${estilo} w-full min-h-[40px] rounded-lg border font-semibold text-xs hover:border-orange-500 transition-colors`
this.marcar(botao.dataset.linhaId, "aparelho", dados.editado)
})
}
salvarRotulo() {
this.enviar(`${this.urlValue}/atualizar_rotulo`, { rotulo_plano: this.rotuloTarget.value })
.then(() => this.agendarPreview())
}
salvarMotorista() {
// Não mexe na prévia: o nome do motorista não sai impresso — a linha do
// rodapé é preenchida à mão na retirada.
this.enviar(`${this.urlValue}/atualizar_veiculo`, {
veiculo: this.veiculoValue,
motorista: this.motoristaTarget.value
})
}
// ── Navegação e filtro ──────────────────────────────────────────────────
trocarVeiculo() {
window.location.search = `?veiculo=${encodeURIComponent(this.veiculoTarget.value)}`
}
anterior() { this.mover(-1) }
proximo() { this.mover(1) }
mover(passo) {
const select = this.veiculoTarget
const destino = select.selectedIndex + passo
if (destino < 0 || destino >= select.options.length) return
select.selectedIndex = destino
this.trocarVeiculo()
}
filtrar(event) {
const termo = event.currentTarget.value.trim().toLowerCase()
this.linhaTargets.forEach((linha) => {
linha.hidden = termo !== "" && !linha.dataset.busca.includes(termo)
})
}
atualizarPreview() {
if (!this.hasPreviewTarget) return
// Cache-bust em vez de reload(): reload reinicializa o visualizador de PDF do
// navegador e a tela pisca a cada salvamento.
this.previewTarget.src =
`${this.pdfUrlValue}?veiculo=${encodeURIComponent(this.veiculoValue)}&t=${Date.now()}`
}
// ── Infra ───────────────────────────────────────────────────────────────
gravar(linhaId, campo, valor, input, botao, aoConcluir) {
this.status("Salvando…")
this.enviar(`${this.urlValue}/atualizar_linha`, { linha_id: linhaId, campo, valor })
.then((dados) => {
if (!dados || !dados.ok) return
if (input) {
// Escrevemos de volta o valor QUE O SERVIDOR GRAVOU: a escrita é
// last-write-wins, e assim uma sobreposição de duas abas fica visível em
// vez de silenciosa.
input.value = dados.valor
input.dataset.original = dados.valor
}
this.marcar(linhaId, campo, dados.editado)
if (aoConcluir) aoConcluir(dados)
this.status("Salvo")
this.agendarPreview()
})
.catch(() => this.status("Não deu para salvar — tente de novo"))
}
marcar(linhaId, campo, editado) {
const alvo = this.element.querySelector(`[data-romaneio-marcador="${linhaId}-${campo}"]`)
if (!alvo) return
if (!editado) {
alvo.innerHTML = ""
return
}
alvo.innerHTML =
`<button type="button" title="Editado — não será sobrescrito na reimportação. Clique para restaurar o valor do plano." ` +
`data-action="romaneio#restaurar" data-linha-id="${linhaId}" data-campo="${campo}" ` +
`class="text-orange-400 hover:text-orange-200 text-xs leading-none">●</button>`
}
agendarPreview() {
if (this.timerPreview) clearTimeout(this.timerPreview)
this.timerPreview = setTimeout(() => this.atualizarPreview(), this.constructor.ATRASO_PREVIEW)
}
enviar(url, corpo) {
return fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content
},
body: JSON.stringify(corpo)
}).then((r) => (r.ok ? r.json() : Promise.reject(r)))
}
status(texto) {
if (this.hasStatusTarget) this.statusTarget.textContent = texto
}
}

View File

@@ -166,6 +166,11 @@ module Permissao
'admin.planilha_simpli_route' => {
grupo: 'admin', label: 'Gerar planilha de carga',
descricao: 'Download do .xlsx de importação do SimpliRoute.'
},
'admin.romaneio' => {
grupo: 'admin', label: 'Gerar romaneio de entrega',
descricao: 'Importa o plano do dia, edita a tabela e imprime o "CONTROLE DE ' \
'ENTREGA" que o motorista assina ao receber a carga.'
}
}.freeze
@@ -189,6 +194,7 @@ module Permissao
consolidacao.gerir_motoristas consolidacao.exportar_pdf
notificacao.contatos notificacao.contatos_gerenciar notificacao.envios
admin.usuarios admin.configuracoes admin.auditoria admin.planilha_simpli_route
admin.romaneio
].freeze,
'operador' => %w[
dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas

71
app/models/romaneio.rb Normal file
View File

@@ -0,0 +1,71 @@
# app/models/romaneio.rb
#
# Um plano do SimpliRoute importado para virar o "CONTROLE DE ENTREGA" — a folha
# que o motorista assina ao retirar a carga, uma página A4 paisagem por veículo.
#
# O romaneio é NOSSO (tabela gravável). As fontes continuam somente leitura: o
# espelho de rastreio (Entrega) e as tabelas de operação (gade_entregas_*).
class Romaneio < ApplicationRecord
ORIGENS = %w[api espelho planilha].freeze
# Máximo de paradas que cabem numa folha. O número é da GEOMETRIA do PDF, então a
# fonte é Pdf::RomaneioPdf — duplicar aqui criaria duas verdades, e o dia em que
# alguém mexesse na margem a tela passaria a avisar "sai em 1 folha" para um
# romaneio que sai em 2. Lido em tempo de chamada (e não numa constante) para não
# amarrar a ordem de carga dos autoloads.
def self.linhas_por_folha
Pdf::RomaneioPdf::MAX_LINHAS
end
belongs_to :importado_por, class_name: 'User', optional: true
has_many :romaneio_veiculos, -> { order(:posicao, :veiculo) },
dependent: :destroy, inverse_of: :romaneio
has_many :romaneio_linhas, dependent: :destroy, inverse_of: :romaneio
validates :planned_date, presence: true
validates :origem, inclusion: { in: ORIGENS }
validates :planned_date, uniqueness: { scope: :operacao_tabela }
scope :recentes, -> { order(planned_date: :desc, id: :desc) }
# Linhas que entram no PDF: as que continuam no plano, na ordem do roteiro.
# `removido_em` marca a parada que saiu do plano mas tinha edição humana — ela
# fica visível na tela para o operador decidir, e NUNCA sai impressa.
def linhas_vigentes
romaneio_linhas.where(removido_em: nil).order(:veiculo, :ordem, :id)
end
def linhas_removidas
romaneio_linhas.where.not(removido_em: nil).order(:veiculo, :ordem, :id)
end
# { 'GADE_001' => 29, ... } — alimenta o seletor de veículo e o aviso de folhas.
def contagem_por_veiculo
@contagem_por_veiculo ||= linhas_vigentes.reorder(nil).group(:veiculo).count
end
def folhas_de(veiculo)
total = contagem_por_veiculo[veiculo].to_i
return 1 if total.zero?
(total.to_f / self.class.linhas_por_folha).ceil
end
# Veículos que estouram uma folha — a tela avisa antes de imprimir, porque
# descobrir isso só no papel é descobrir tarde.
def veiculos_multifolha
contagem_por_veiculo.select { |_v, n| n > self.class.linhas_por_folha }
end
def operacao_label
operacao_tabela.present? ? Operacao.label(operacao_tabela) : nil
end
# Rótulo do canto direito do PDF ("EMAD 09.2026"). Só uma SUGESTÃO: não existe
# fonte canônica para esse texto, então ele é editável na tela.
def self.rotulo_sugerido(tabela, data)
base = tabela.present? ? Operacao.label(tabela).split.first : nil
[base, data.to_date.strftime('%m.%Y')].compact.join(' ')
end
end

View File

@@ -0,0 +1,68 @@
# app/models/romaneio_linha.rb
#
# Uma PARADA do romaneio — vira uma linha da tabela do PDF.
#
# ⚠️ Nome de tabela fixado: "RomaneioLinha".tableize já dá `romaneio_linhas`, mas a
# declaração explícita é a trava contra alguém acrescentar um irregular ao
# config/initializers/inflections.rb e mudar isso sem perceber (ConsolidacaoMotorista
# já quebrou /motorista em produção exatamente assim). Coberto por
# spec/models/table_names_spec.rb.
class RomaneioLinha < ApplicationRecord
self.table_name = 'romaneio_linhas'
# Os únicos campos que o operador edita — e a whitelist que o controller usa
# para não deixar um PATCH escrever em `veiculo`, `ordem` ou `chave_origem`.
#
# `veiculo` e `ordem` NÃO são editáveis de propósito: mover uma parada de veículo
# aqui produziria um romaneio que contradiz a rota que o motorista recebe no
# celular — dois papéis, mesma entrega, respostas diferentes. Veículo errado se
# corrige no SimpliRoute e se reimporta.
CAMPOS_EDITAVEIS = %w[nota_fiscal nome endereco aparelho telefones].freeze
belongs_to :romaneio, inverse_of: :romaneio_linhas
validates :veiculo, :chave_origem, presence: true
scope :vigentes, -> { where(removido_em: nil) }
scope :removidas, -> { where.not(removido_em: nil) }
scope :do_veiculo, ->(veiculo) { where(veiculo: veiculo) }
# Identidade ESTÁVEL entre reimportações. A NF quando existe; senão um digest do
# título + endereço, porque nem toda linha do plano casa "NF 90507 - FULANO" e
# duas linhas sem NF colidiriam no índice único, derrubando a importação inteira.
#
# Veículo e ordem ficam FORA da chave: mudam quando o plano é reotimizado e a
# parada continua sendo a mesma entrega.
def self.chave_para(nota_fiscal:, titulo: nil, endereco: nil)
nf = nota_fiscal.to_s.strip
return nf if nf.present?
"T:#{Digest::MD5.hexdigest("#{titulo}|#{endereco}")}"
end
def editado?(campo)
campos_editados.include?(campo.to_s)
end
def editada?
campos_editados.present?
end
# Valor que o PLANO trouxe na última importação — é o que o botão "restaurar"
# devolve, e o que a reconciliação compara.
def valor_do_plano(campo)
origem[campo.to_s].to_s
end
# Marca o campo como tocado por humano. É ISTO que sobrevive à reimportação:
# o dirty tracking é POR CAMPO, não por linha — "corrigi o telefone" não pode
# significar "também respondo pelo endereço" e congelar um endereço velho quando
# o SimpliRoute regeocodificar.
def marcar_editado!(campo)
self.campos_editados = (campos_editados + [campo.to_s]).uniq
end
def desmarcar_editado!(campo)
self.campos_editados = campos_editados - [campo.to_s]
end
end

View File

@@ -0,0 +1,17 @@
# app/models/romaneio_veiculo.rb
#
# Uma FOLHA do romaneio: um veículo, uma página (ou mais, quando passa de
# Romaneio::LINHAS_POR_FOLHA paradas).
#
# ⚠️ `motorista` NÃO é impresso. O rodapé do documento traz "MOTORISTA: ______"
# para preenchimento à mão — é assim que a folha é assinada hoje, e o programa
# Python que originou esta tela também coletava o nome sem usá-lo. O campo existe
# para o operador saber, na tela, de quem é cada carro enquanto revisa.
class RomaneioVeiculo < ApplicationRecord
belongs_to :romaneio, inverse_of: :romaneio_veiculos
validates :veiculo, presence: true
validates :veiculo, uniqueness: { scope: :romaneio_id }
scope :ordenados, -> { order(:posicao, :veiculo) }
end

View File

@@ -129,6 +129,7 @@ class User < ApplicationRecord
['admin.usuarios', :admin_usuarios_path],
['admin.perfis', :admin_perfis_path],
['admin.configuracoes', :admin_configuracoes_path],
['admin.romaneio', :admin_romaneios_path],
['notificacao.contatos', :admin_contatos_path],
['notificacao.eventos', :admin_eventos_path],
['notificacao.envios', :admin_envios_path]

View File

@@ -0,0 +1,19 @@
# app/policies/romaneio_policy.rb
#
# Uma chave por TELA, como manda o comentário de Permissao: importar o plano, editar
# a tabela e imprimir são a MESMA operação do ponto de vista de quem carrega o
# caminhão — quebrar mais fino encheria a tela de perfil de caixinhas que ninguém
# sabe responder.
class RomaneioPolicy < ApplicationPolicy
def index? = pode?('admin.romaneio')
def show? = index?
def create? = index?
def destroy? = index?
def importar_planilha? = index?
def reimportar? = index?
def pdf? = index?
def atualizar_linha? = index?
def atualizar_veiculo? = index?
def atualizar_rotulo? = index?
end

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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.

View File

@@ -0,0 +1,48 @@
<%# Divergências explicadas ONDE elas aparecem — não em documentação. %>
<% multifolha = @romaneio.veiculos_multifolha %>
<% if @colunas_ausentes.include?('status') && @romaneio.operacao_tabela.present? %>
<div class="p-4 bg-amber-900/20 border border-amber-500/40 rounded-xl text-amber-200/90 text-sm flex items-start gap-2">
<span class="text-base leading-none">⚠️</span>
<p>
A operação <strong><%= @romaneio.operacao_label %></strong> não tem a coluna
<strong>STATUS</strong>, então não dá para saber quem é paciente novo — a coluna
<strong>APARELHO</strong> saiu vazia para todos e precisa ser preenchida à mão.
Um romaneio em que ninguém recebe aparelho, entregue calado, vira aparelho não entregue.
</p>
</div>
<% end %>
<% if @colunas_ausentes.include?('telefones') && @romaneio.operacao_tabela.present? %>
<div class="p-4 bg-amber-900/20 border border-amber-500/40 rounded-xl text-amber-200/90 text-sm flex items-start gap-2">
<span class="text-base leading-none">☎️</span>
<p>
A operação <strong><%= @romaneio.operacao_label %></strong> não tem a coluna
<strong>TELEFONES</strong>. O que aparece na tabela veio do “TEL:” das anotações do
plano, que nem sempre traz todos os números.
</p>
</div>
<% end %>
<% if @romaneio.operacao_tabela.blank? %>
<div class="p-4 bg-blue-900/20 border border-blue-500/40 rounded-xl text-blue-200/90 text-sm flex items-start gap-2">
<span class="text-base leading-none"></span>
<p>
Este romaneio foi importado <strong>sem operação vinculada</strong>: a coluna APARELHO
não é preenchida sozinha e o telefone vem só das anotações do plano.
</p>
</div>
<% end %>
<% if multifolha.any? %>
<div class="p-4 bg-blue-900/20 border border-blue-500/40 rounded-xl text-blue-200/90 text-sm flex items-start gap-2">
<span class="text-base leading-none">🖨️</span>
<p>
<% multifolha.each do |veiculo, total| %>
<strong><%= veiculo %></strong> tem <%= total %> paradas →
sai em <%= @romaneio.folhas_de(veiculo) %> folhas<%= ',' unless veiculo == multifolha.keys.last %>
<% end %>
— cada folha vai com o próprio bloco de assinatura, e a numeração continua entre elas.
</p>
</div>
<% end %>

View File

@@ -0,0 +1,13 @@
<%# Uma célula editável + o marcador de "editado". %>
<div class="flex items-center gap-1">
<input type="text"
value="<%= linha.public_send(campo) %>"
data-romaneio-target="celula"
data-action="change->romaneio#salvarCelula"
data-linha-id="<%= linha.id %>"
data-campo="<%= campo %>"
data-original="<%= linha.public_send(campo) %>"
class="w-full bg-transparent border border-transparent hover:border-white/10 focus:border-orange-500
focus:bg-[#1a1a1a] rounded-lg px-2 py-2 text-gray-100 focus:outline-none min-h-[40px]">
<%= render 'marcador', linha: linha, campo: campo %>
</div>

View File

@@ -0,0 +1,55 @@
<%# Seletor de veículo + os dois campos de cabeçalho da folha. %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Veículo</label>
<div class="flex gap-2">
<%# ao lado do select: com 72 veículos, uma lista lateral vira armadilha
de scroll, mas percorrer o plano carro a carro é o que o operador faz. %>
<button type="button" data-action="romaneio#anterior" aria-label="Veículo anterior"
class="px-3 min-w-[48px] min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
text-white hover:bg-[#242424]"></button>
<select data-romaneio-target="veiculo" data-action="romaneio#trocarVeiculo"
class="flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
focus:outline-none focus:border-orange-500 min-h-[48px]">
<% @veiculos.each do |v| %>
<option value="<%= v.veiculo %>" <%= 'selected' if v.veiculo == @veiculo %>>
<%= v.veiculo %> (<%= @romaneio.contagem_por_veiculo[v.veiculo].to_i %>)
</option>
<% end %>
</select>
<button type="button" data-action="romaneio#proximo" aria-label="Próximo veículo"
class="px-3 min-w-[48px] min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
text-white hover:bg-[#242424]"></button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
PLANO <span class="text-gray-500 font-normal">— texto do canto direito do PDF</span>
</label>
<input type="text" value="<%= @romaneio.rotulo_plano %>"
data-romaneio-target="rotulo" data-action="change->romaneio#salvarRotulo"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
focus:outline-none focus:border-orange-500 min-h-[48px]">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
Motorista <span class="text-gray-500 font-normal">— só na tela, não sai impresso</span>
</label>
<% atual = @veiculos.find { |v| v.veiculo == @veiculo } %>
<input type="text" value="<%= atual&.motorista %>" placeholder="Quem leva este carro"
data-romaneio-target="motorista" data-action="change->romaneio#salvarMotorista"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Buscar nesta folha</label>
<input type="search" placeholder="NF, nome ou endereço…"
data-action="input->romaneio#filtrar"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
</div>
</div>

View File

@@ -0,0 +1,12 @@
<%# Ponto laranja = campo tocado por humano, e por isso protegido na reimportação.
O botão ↺ devolve o valor do plano (e destrava o campo de novo) — sem ele, uma
tecla acidental seria permanente. %>
<span class="shrink-0 w-4 flex items-center justify-center"
data-romaneio-marcador="<%= linha.id %>-<%= campo %>">
<% if linha.editado?(campo) %>
<button type="button" title="Editado — não será sobrescrito na reimportação. Clique para restaurar o valor do plano."
data-action="romaneio#restaurar"
data-linha-id="<%= linha.id %>" data-campo="<%= campo %>"
class="text-orange-400 hover:text-orange-200 text-xs leading-none">●</button>
<% end %>
</span>

View File

@@ -0,0 +1,21 @@
<%# Paradas que saíram do plano mas tinham edição humana. Não são impressas, e não
são apagadas: quem decide o que fazer com elas é o operador. %>
<% if linhas.any? %>
<div class="bg-[#111] border border-amber-500/30 rounded-2xl p-4">
<h2 class="text-amber-200 font-semibold text-sm mb-1">
<%= linhas.size %> parada(s) saíram do plano
</h2>
<p class="text-gray-400 text-xs mb-3">
Elas tinham edição sua, então foram guardadas em vez de apagadas — e não entram no PDF.
Se voltarem ao plano numa próxima importação, voltam sozinhas para a folha.
</p>
<ul class="space-y-1 text-sm">
<% linhas.each do |linha| %>
<li class="text-gray-500 line-through">
<span class="text-gray-400"><%= linha.veiculo %></span> ·
NF <%= linha.nota_fiscal.presence || '—' %> · <%= linha.nome %>
</li>
<% end %>
</ul>
</div>
<% end %>

View File

@@ -0,0 +1,59 @@
<%# Tabela editável do veículo selecionado.
SEM paginação de propósito: acima de 1366px a lista inteira é o certo — esconder
item em monitor grande atrapalha até o Ctrl+F do navegador, e conferir romaneio é
exatamente procurar uma NF na lista. A busca acima filtra sem esconder nada.
<input> por célula, e não contenteditable: colar do Excel num contenteditable
traz HTML junto. %>
<div class="bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
<div class="px-4 py-3 border-b border-white/5 flex items-center justify-between">
<h2 class="text-white font-semibold text-sm"><%= linhas.size %> paradas</h2>
<span class="text-xs text-gray-500" data-romaneio-target="status">Alterações salvam sozinhas</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-[#1a1a1a] text-gray-400 text-xs uppercase tracking-wider">
<tr>
<th class="text-left px-2 py-2 w-10">#</th>
<th class="text-left px-2 py-2 w-28">Nota fiscal</th>
<th class="text-left px-2 py-2 w-56">Nome</th>
<th class="text-left px-2 py-2">Endereço</th>
<th class="text-center px-2 py-2 w-24">Aparelho</th>
<th class="text-left px-2 py-2 w-44">Telefones</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<% linhas.each_with_index do |linha, i| %>
<tr data-romaneio-target="linha"
data-busca="<%= [linha.nota_fiscal, linha.nome, linha.endereco].join(' ').downcase %>">
<td class="px-2 py-1 text-gray-500 text-xs"><%= i + 1 %></td>
<% %w[nota_fiscal nome endereco].each do |campo| %>
<td class="px-1 py-1">
<%= render 'celula', linha: linha, campo: campo %>
</td>
<% end %>
<%# APARELHO só assume 'SIM' ou vazio: um botão de dois estados é alvo de
toque de verdade e não deixa digitar um terceiro valor. %>
<td class="px-1 py-1 text-center">
<button type="button"
data-action="romaneio#alternarAparelho"
data-linha-id="<%= linha.id %>"
data-valor="<%= linha.aparelho %>"
class="<%= linha.aparelho == 'SIM' ? 'bg-green-600/20 text-green-300 border-green-600/40' : 'bg-[#1a1a1a] text-gray-500 border-white/10' %>
w-full min-h-[40px] rounded-lg border font-semibold text-xs hover:border-orange-500 transition-colors">
<%= linha.aparelho.presence || '—' %>
</button>
<%= render 'marcador', linha: linha, campo: 'aparelho' %>
</td>
<td class="px-1 py-1">
<%= render 'celula', linha: linha, campo: 'telefones' %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,144 @@
<%# app/views/admin/romaneios/index.html.erb %>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-white">Romaneio de entrega</h1>
<p class="text-gray-400 text-sm mt-0.5">
Monta o "CONTROLE DE ENTREGA" — uma folha por veículo, assinada pelo motorista na retirada
</p>
</div>
<%= render 'shared/flash' %>
<div class="p-4 bg-blue-900/20 border border-blue-500/40 rounded-xl text-blue-200/90 text-sm flex items-start gap-2">
<span class="text-base leading-none"></span>
<p>
A coluna <strong>APARELHO</strong> é marcada sozinha para quem está como
<strong>NOVO</strong> na operação do mês, e o <strong>telefone</strong> vem da própria
operação — não é preciso subir uma segunda planilha. Tudo continua editável na tela
antes de imprimir.
</p>
</div>
<%# Importação %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-5 space-y-4">
<h2 class="text-white font-semibold">Importar plano</h2>
<%= form_with url: admin_romaneios_path, method: :post, multipart: true,
data: { turbo: false }, class: 'space-y-4' do %>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Data do plano</label>
<input type="date" name="planned_date" required value="<%= @data_padrao %>"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
focus:outline-none focus:border-orange-500 min-h-[48px]">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
Operação do mês
<span class="text-gray-500 font-normal">(APARELHO e telefone)</span>
</label>
<select name="operacao_tabela"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
focus:outline-none focus:border-orange-500 min-h-[48px]">
<option value="">Sem operação — só o que o plano trouxer</option>
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>"><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
PLANO <span class="text-gray-500 font-normal">(canto direito do PDF)</span>
</label>
<input type="text" name="rotulo_plano" placeholder="Ex.: EMAD 09.2026"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 sm:items-center">
<button type="submit"
class="px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl
transition-colors min-h-[48px] whitespace-nowrap">
<%= icone :buscar, cor: nil %> Buscar no SimpliRoute
</button>
<%# Reserva: o plano do dia pode ainda não estar publicado. %>
<details class="flex-1">
<summary class="cursor-pointer text-sm text-gray-400 hover:text-white select-none py-3">
Ou enviar a planilha do plano (.xlsx)
</summary>
<div class="mt-2 flex flex-col sm:flex-row gap-3">
<input type="file" name="arquivo" accept=".xlsx"
class="flex-1 text-sm text-gray-300 file:mr-3 file:py-2.5 file:px-4 file:rounded-lg
file:border-0 file:bg-[#1a1a1a] file:text-gray-200 file:cursor-pointer">
<button type="submit" formaction="<%= importar_planilha_admin_romaneios_path %>"
class="px-5 py-3 bg-[#1a1a1a] hover:bg-[#242424] text-white border border-white/10
rounded-xl min-h-[48px] whitespace-nowrap">
<%= icone :baixar, cor: nil %> Enviar planilha
</button>
</div>
<p class="text-xs text-gray-500 mt-2">
Use quando o plano do dia ainda não estiver publicado no SimpliRoute.
O arquivo é lido na hora e não fica guardado no sistema.
</p>
</details>
</div>
<% end %>
</div>
<%# Romaneios já importados %>
<div class="bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
<% if @romaneios.empty? %>
<div class="p-8 text-center">
<p class="text-gray-400">Nenhum romaneio ainda.</p>
<p class="text-gray-500 text-sm mt-1">Escolha a data do plano acima e clique em “Buscar no SimpliRoute”.</p>
</div>
<% else %>
<table class="w-full text-sm">
<thead class="bg-[#1a1a1a] text-gray-400 text-xs uppercase tracking-wider">
<tr>
<th class="text-left px-4 py-3">Data</th>
<th class="text-left px-4 py-3">Plano</th>
<th class="text-left px-4 py-3">Operação</th>
<th class="text-left px-4 py-3">Veículos</th>
<th class="text-left px-4 py-3">Origem</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<% @romaneios.each do |r| %>
<tr class="hover:bg-white/5">
<td class="px-4 py-3 text-white"><%= l r.planned_date, format: '%d/%m/%Y' %></td>
<td class="px-4 py-3 text-gray-200"><%= r.rotulo_plano.presence || '—' %></td>
<td class="px-4 py-3 text-gray-400"><%= r.operacao_label || '—' %></td>
<td class="px-4 py-3 text-gray-400"><%= r.romaneio_veiculos.size %></td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-md text-xs bg-white/5 text-gray-300"><%= r.origem %></span>
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<%= link_to rotulo(:editar, 'Abrir'), admin_romaneio_path(r),
class: 'text-orange-400 hover:text-orange-300 font-medium' %>
<%# button_to em BLOCO: sem bloco ele vira <input type="submit">, que
não aceita o SVG do ícone dentro (ver ApplicationHelper#rotulo). %>
<%= button_to admin_romaneio_path(r), method: :delete,
form: { data: { turbo_confirm: 'Excluir este romaneio e todas as edições feitas nele?' },
class: 'inline-block ml-3' },
class: 'text-gray-500 hover:text-red-400' do %>
<%= icone :excluir, cor: nil %> Excluir
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<%# app/views/admin/romaneios/show.html.erb
Editor do romaneio: tabela editável à esquerda, o PDF REAL à direita.
A prévia é um <iframe> de mesma origem, e isso não é preferência: o CSP deste
app está em modo enforcing com `object_src :none` (config/initializers/
content_security_policy.rb), o que bloqueia <embed> e <object>; não há
`frame_src`, então ele cai em `default_src :self` e o iframe passa. Uma data:
URL seria barrada pelo mesmo fallback. %>
<div class="space-y-5"
data-controller="romaneio"
data-romaneio-url-value="<%= admin_romaneio_path(@romaneio) %>"
data-romaneio-pdf-url-value="<%= pdf_admin_romaneio_path(@romaneio) %>"
data-romaneio-veiculo-value="<%= @veiculo %>">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-white">
Romaneio de <%= l @romaneio.planned_date, format: '%d/%m/%Y' %>
</h1>
<p class="text-gray-400 text-sm mt-0.5">
<%= @romaneio.operacao_label || 'Sem operação vinculada' %> ·
<%= @romaneio.contagem_por_veiculo.values.sum %> paradas em
<%= @veiculos.size %> veículo(s) ·
importado de <%= @romaneio.origem %>
<%= "em #{l @romaneio.importado_em, format: '%d/%m %H:%M'}" if @romaneio.importado_em %>
</p>
</div>
<div class="flex flex-wrap gap-2">
<%= link_to rotulo(:baixar, 'Baixar todos'),
pdf_admin_romaneio_path(@romaneio),
class: 'px-4 py-2.5 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl min-h-[44px] inline-flex items-center' %>
<%# Em BLOCO: button_to sem bloco gera <input type="submit">, que não aceita
o SVG do ícone (ver ApplicationHelper#rotulo). %>
<%= button_to reimportar_admin_romaneio_path(@romaneio, veiculo: @veiculo), method: :post,
form: { data: { turbo_confirm: 'Rebuscar o plano? Os campos que você editou são preservados.' } },
class: 'px-4 py-2.5 bg-[#1a1a1a] hover:bg-[#242424] text-white border border-white/10 rounded-xl min-h-[44px]' do %>
<%= icone :restaurar, cor: nil %> Reimportar plano
<% end %>
<%= link_to 'Voltar', admin_romaneios_path,
class: 'px-4 py-2.5 text-gray-400 hover:text-white min-h-[44px] inline-flex items-center' %>
</div>
</div>
<%= render 'shared/flash' %>
<% if @veiculos.empty? %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-8 text-center">
<p class="text-gray-400">Este romaneio está sem paradas.</p>
<p class="text-gray-500 text-sm mt-1">Clique em “Reimportar plano” ou envie a planilha do plano na tela anterior.</p>
</div>
<% else %>
<%= render 'avisos' %>
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,560px)] gap-5 items-start">
<div class="space-y-4">
<%= render 'controles' %>
<%= render 'tabela', linhas: @linhas %>
<%= render 'removidas', linhas: @removidas %>
</div>
<%# Prévia — no celular vira um bloco recolhido: 6 colunas em tela pequena
exigiriam zoom, e o que importa ali é editar, não conferir o layout. %>
<div class="lg:sticky lg:top-4">
<details class="lg:hidden bg-[#111] border border-white/5 rounded-2xl p-4 mb-4">
<summary class="cursor-pointer text-white font-medium select-none min-h-[44px] flex items-center">
<%= icone :visualizar %> Ver prévia do PDF
</summary>
<%= link_to 'Abrir o PDF deste veículo',
pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo),
class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %>
</details>
<div class="hidden lg:block bg-[#111] border border-white/5 rounded-2xl p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-white font-semibold text-sm">Prévia — <%= @veiculo %></h2>
<button type="button" data-action="romaneio#atualizarPreview"
class="text-xs text-gray-400 hover:text-white px-3 py-2 rounded-lg hover:bg-white/5">
<%= icone :restaurar, tamanho: 'w-4 h-4' %> Atualizar prévia
</button>
</div>
<iframe data-romaneio-target="preview"
src="<%= pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo) %>"
class="w-full h-[78vh] rounded-xl border border-white/10 bg-white"
title="Pré-visualização do romaneio do veículo <%= @veiculo %>"></iframe>
</div>
</div>
</div>
<% end %>
</div>

View File

@@ -56,7 +56,8 @@
<%# Administração — a seção inteira some quando nenhum item dela está liberado. %>
<% itens_admin = %w[admin.usuarios admin.perfis admin.configuracoes admin.auditoria
admin.planilha_simpli_route admin.edicao_lancamento] %>
admin.planilha_simpli_route admin.edicao_lancamento
admin.romaneio] %>
<% if itens_admin.any? { |chave| current_user.pode?(chave) } %>
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
@@ -75,6 +76,9 @@
<% if current_user.pode?('admin.planilha_simpli_route') %>
<%= nav_link_to 'Planilha SimpliRoute', admin_planilha_simpli_route_path, icon: :planilha %>
<% end %>
<% if current_user.pode?('admin.romaneio') %>
<%= nav_link_to 'Romaneio de entrega', admin_romaneios_path, icon: :imprimir %>
<% end %>
<% if current_user.pode?('admin.edicao_lancamento') %>
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %>
<% end %>