diff --git a/.env.example b/.env.example
index 5106d4c..49b6b94 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,13 @@
# Use `development` só na sua máquina local.
RAILS_ENV=production
+# Fuso horário do sistema operacional dentro dos containers.
+# Já é o padrão no Dockerfile — só mexa se a operação mudar de estado.
+# ⚠️ Isto define o FUSO, não a HORA. Se a data/hora do servidor está errada
+# (atrasa, ou volta errada depois de reboot/deploy), o problema é o relógio
+# do HOST: rode uma vez `sudo bash deploy/ntp-seguro.sh`.
+TZ=America/Sao_Paulo
+
# ── Banco de dados (seu PostgreSQL já existente) ─────────────
# Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL
DATABASE_URL=postgresql://postgres:senha_segura@SEU_IP:5432/logistica_db
diff --git a/Dockerfile b/Dockerfile
index f970493..f97f83d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,22 @@
FROM ruby:3.2.2-slim
+# Fuso horário do container.
+#
+# POR QUE ISSO IMPORTA (caso real): a imagem sobe em UTC. O Rails até mostra a
+# hora certa (`config.time_zone = "America/Sao_Paulo"`), mas TUDO que é do
+# sistema operacional continuava 3h adiantado — timestamp de log, `date` nos
+# scripts de bin/ e, o pior, o CRON: `every "*/30 8-18"` no config/schedule.rb
+# rodava das 05h às 15h de Brasília, não das 08h às 18h.
+#
+# O cron do Debian lê `/etc/localtime`, não a variável TZ — por isso o symlink
+# além do ENV. Os dois juntos cobrem processo Ruby e daemon de cron.
+ENV TZ=America/Sao_Paulo
+ENV DEBIAN_FRONTEND=noninteractive
+
# Dependências do sistema
+# tzdata: explícito de propósito. O zoneinfo já vem na imagem hoje, mas se um
+# rebuild futuro pegar uma base enxuta o symlink acima quebra em silêncio e a
+# hora volta a ficar errada — que é exatamente o bug que estamos fechando.
RUN apt-get update -qq && apt-get install -y \
build-essential \
libpq-dev \
@@ -10,6 +26,9 @@ RUN apt-get update -qq && apt-get install -y \
git \
libvips \
cron \
+ tzdata \
+ && ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime \
+ && echo "$TZ" > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
# Diretório da app
@@ -27,4 +46,6 @@ COPY . .
EXPOSE 3000
-CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
+# O boot real (migrations, cron, servidor) mora em bin/docker-boot — ver os
+# comentários lá. CMD aqui é só o fallback de quem roda a imagem sem o compose.
+CMD ["bin/docker-boot"]
diff --git a/Gemfile b/Gemfile
index a8683a4..d69d0a2 100644
--- a/Gemfile
+++ b/Gemfile
@@ -29,6 +29,11 @@ gem "prawn-table"
# Planilhas (XLSX) — geração da carga de importação do SimpliRoute
gem "caxlsx"
+# Leitura do .xlsx do PLANO do SimpliRoute (reserva do romaneio, quando a API não
+# responde ou o plano do dia ainda não foi publicado). A roo pina rubyzip < 3.0;
+# o único outro consumidor é o selenium-webdriver (grupo de teste, aceita >= 1.2.2,
+# < 4.0), então o bundler rebaixa o rubyzip sem quebrar nada.
+gem "roo", "~> 2.10"
gem "rqrcode" # QR code no holerite (login rápido motorista)
gem "chunky_png" # renderiza QR como PNG para embed no Prawn
@@ -47,6 +52,10 @@ group :development, :test do
gem "rspec-rails"
gem "factory_bot_rails"
gem "faker"
+ # Lê o PDF gerado (páginas e texto) nos specs. Sem ele, o único jeito de testar
+ # um PDF do Prawn seria olhar o arquivo: as fontes TTF são subsetadas, então o
+ # texto não é "grepável" no binário.
+ gem "pdf-inspector", require: false
end
group :development do
diff --git a/Gemfile.lock b/Gemfile.lock
index cdfc2a2..ffa2121 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,6 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
+ Ascii85 (2.0.1)
actioncable (7.1.6)
actionpack (= 7.1.6)
activesupport (= 7.1.6)
@@ -82,6 +83,7 @@ GEM
tzinfo (~> 2.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
+ afm (1.0.0)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
@@ -100,6 +102,11 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
+ caxlsx (4.5.0)
+ htmlentities (~> 4.3, >= 4.3.4)
+ marcel (~> 1.0)
+ nokogiri (~> 1.10, >= 1.10.4)
+ rubyzip (>= 2.4, < 4)
cgi (0.5.1)
chronic (0.10.2)
chunky_png (1.4.0)
@@ -139,6 +146,8 @@ GEM
net-http (~> 0.5)
globalid (1.3.0)
activesupport (>= 6.1)
+ hashery (2.1.2)
+ htmlentities (4.4.2)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (2.0.2)
@@ -192,6 +201,13 @@ GEM
orm_adapter (0.5.0)
pagy (9.4.0)
pdf-core (0.10.0)
+ pdf-inspector (1.3.0)
+ pdf-reader (>= 1.0, < 3.0.a)
+ pdf-reader (2.16.0)
+ Ascii85 (>= 1.0, < 3.0, != 2.0.0)
+ afm (>= 0.2.1, < 2)
+ hashery (~> 2.0)
+ ttfunk
pg (1.6.3-x86_64-linux)
pp (0.6.3)
prettyprint
@@ -269,6 +285,9 @@ GEM
actionpack (>= 7.0)
railties (>= 7.0)
rexml (3.4.4)
+ roo (2.10.1)
+ nokogiri (~> 1)
+ rubyzip (>= 1.3.0, < 3.0.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
@@ -290,7 +309,7 @@ GEM
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.7)
- rubyzip (3.4.0)
+ rubyzip (2.4.1)
securerandom (0.4.1)
selenium-webdriver (4.44.0)
base64 (~> 0.2)
@@ -344,6 +363,7 @@ PLATFORMS
DEPENDENCIES
annotate
capybara
+ caxlsx
chunky_png
debug
devise
@@ -354,6 +374,7 @@ DEPENDENCIES
importmap-rails
jbuilder
pagy (~> 9.0)
+ pdf-inspector
pg (~> 1.1)
prawn
prawn-table
@@ -362,6 +383,7 @@ DEPENDENCIES
pundit
rack-mini-profiler
rails (~> 7.1.0)
+ roo (~> 2.10)
rqrcode
rspec-rails
selenium-webdriver
diff --git a/README.md b/README.md
index eccea02..9036554 100644
--- a/README.md
+++ b/README.md
@@ -3068,3 +3068,122 @@ caminho do servidor e o trace inteiro — para **qualquer pessoa** que provocass
celular, onde não há faixa lateral livre.
+
+---
+
+
+🕐 Relógio, restart automático e migrations no boot
+
+## O problema relatado
+
+> "toda vez que faço o deploy ou reinicio o Docker a data e hora ficam erradas."
+
+São **dois problemas diferentes** que se confundem porque aparecem no mesmo lugar:
+
+| Sintoma | Causa | Onde se resolve |
+|---|---|---|
+| Hora **sempre 3h adiantada**, todo restart | O container subia em **UTC** | `Dockerfile` (fuso) |
+| Hora **derivando / errada depois de reboot** | Relógio do **host** sem sincronia | `deploy/ntp-seguro.sh` (NTS) |
+
+### 1. Fuso do container (o "sempre 3h à frente")
+
+A imagem `ruby:3.2.2-slim` sobe em UTC. O Rails até mostrava a hora certa
+(`config.time_zone = "America/Sao_Paulo"`), mas **tudo que era do sistema
+operacional continuava adiantado** — e o pior caso não era visual:
+
+> **O cron rodava no horário errado.** `every "*/30 8-18"` no `config/schedule.rb`
+> executava das **05h às 15h** de Brasília, não das 08h às 18h.
+
+Agora o `Dockerfile` fixa `TZ=America/Sao_Paulo` **e** o symlink de
+`/etc/localtime` — os dois, porque o cron do Debian lê o arquivo, não a
+variável. O mesmo foi feito no `whatsapp/Dockerfile` (Alpine precisa do pacote
+`tzdata`, que não vem na imagem). Dá para ajustar sem rebuild pelo `TZ` do `.env`.
+
+### 2. Hora do host — NTP autenticado (NTS)
+
+**Não dá para sincronizar o relógio de dentro do container**: ele lê o clock do
+kernel do host. Rodar NTP lá dentro exigiria `CAP_SYS_TIME` e mudaria a hora do
+servidor inteiro a partir de um processo da aplicação.
+
+No servidor, uma vez:
+
+```bash
+sudo bash deploy/ntp-seguro.sh
+```
+
+O script instala e configura o **chrony com NTS** (RFC 8915): a troca de chaves
+é por TLS (TCP 4460) e os pacotes NTP vêm assinados, então uma resposta forjada
+no caminho é descartada. NTP comum é UDP sem autenticação — e hora errada aqui
+não é detalhe: ela muda **o dia da consolidação, o recorte do período financeiro
+e a validade da sessão**.
+
+O script ainda: usa **3 fontes independentes** (Cloudflare, Netnod, PTB),
+**desliga as fontes não autenticadas** (deixar o `pool` padrão anularia o ganho),
+desativa o `systemd-timesyncd` (não fala NTS e brigaria pelo relógio), liga o
+`rtcsync` (é o relógio de hardware que dá a hora no boot) e mostra a
+conferência no fim. É idempotente e guarda `.bak` da config.
+
+Conferir depois, a qualquer momento:
+
+```bash
+chronyc tracking # 'System time' deve ficar na casa dos milissegundos
+chronyc -N authdata # NTS ativo em cada fonte (Cook > 0)
+```
+
+> **Firewall:** precisa de saída em **UDP 123** e **TCP 4460**. Sem a 4460 o NTS
+> não fecha e o chrony fica sem fonte.
+
+## Restart automático + migrations
+
+`docker-compose.yml`, serviço `app`: **`restart: unless-stopped`**. Sobe sozinho
+depois de queda do processo e de reboot do servidor, e só fica parado se alguém
+der `docker compose stop/down`. (`always` foi descartado: ele reergue o container
+até depois de um `stop` deliberado, tirando do operador a chance de deixar o
+sistema fora do ar de propósito.)
+
+O boot saiu do `command:` de uma linha só e virou **`bin/docker-boot`**, com o
+motivo de cada passo comentado:
+
+1. **relógio** — imprime a hora e o fuso no log (se aparecer UTC, o rebuild não pegou);
+2. **pid órfão** — sem limpar, o Puma se recusa a subir depois de uma queda;
+3. **gems** — `bundle check || bundle install` (o volume `bundle_cache` sombreia os gems da imagem);
+4. **migrations** — `db:prepare`, **com espera pelo banco**;
+5. **cron** — `whenever --update-crontab` + daemon, *best-effort*;
+6. **Puma**.
+
+Ou seja: **a migration é automática a todo restart** — `db:prepare` cria o banco
+se não existir, aplica as migrations pendentes e só faz seed em banco novo
+(idempotente). Não há passo manual depois do deploy.
+
+> **Por que a espera no passo 4:** o PostgreSQL é externo ao compose. Num reboot
+> do servidor o Rails pode subir antes de o banco aceitar conexão — e agora, com
+> `restart: unless-stopped`, isso viraria um **loop de reinício** parecendo erro
+> de migration. São 10 tentativas × 6s. Se falhar depois disso (migration
+> quebrada, credencial errada), o container sai com erro **de propósito**: melhor
+> do que servir a aplicação contra um schema desatualizado.
+
+## Deploy
+
+```bash
+# 1. No servidor, uma única vez — relógio com NTP autenticado:
+sudo bash deploy/ntp-seguro.sh
+
+# 2. Rebuild obrigatório (o fuso entra na imagem):
+docker compose up -d --build
+
+# 3. Conferir no log que o container está em -03 e não em UTC:
+docker compose logs app | grep '\[boot\] relógio'
+```
+
+## 📂 Arquivos
+
+```
+Dockerfile # TZ=America/Sao_Paulo + tzdata + /etc/localtime; CMD -> bin/docker-boot
+whatsapp/Dockerfile # tzdata (Alpine) + mesmo fuso
+docker-compose.yml # restart: unless-stopped, TZ, command: bin/docker-boot
+bin/docker-boot # boot documentado: migrations com espera, cron, Puma — NOVO
+deploy/ntp-seguro.sh # chrony + NTS no host (rodar 1x, como root) — NOVO
+.env.example # TZ=America/Sao_Paulo + aviso "fuso ≠ hora"
+```
+
+
diff --git a/app/controllers/admin/romaneios_controller.rb b/app/controllers/admin/romaneios_controller.rb
new file mode 100644
index 0000000..cd0dfbb
--- /dev/null
+++ b/app/controllers/admin/romaneios_controller.rb
@@ -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
diff --git a/app/javascript/controllers/romaneio_controller.js b/app/javascript/controllers/romaneio_controller.js
new file mode 100644
index 0000000..d8b723f
--- /dev/null
+++ b/app/javascript/controllers/romaneio_controller.js
@@ -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 =
+ ``
+ }
+
+ 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
+ }
+}
diff --git a/app/models/permissao.rb b/app/models/permissao.rb
index ca9833b..4571c8a 100644
--- a/app/models/permissao.rb
+++ b/app/models/permissao.rb
@@ -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
diff --git a/app/models/romaneio.rb b/app/models/romaneio.rb
new file mode 100644
index 0000000..db46b81
--- /dev/null
+++ b/app/models/romaneio.rb
@@ -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
diff --git a/app/models/romaneio_linha.rb b/app/models/romaneio_linha.rb
new file mode 100644
index 0000000..153207b
--- /dev/null
+++ b/app/models/romaneio_linha.rb
@@ -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
diff --git a/app/models/romaneio_veiculo.rb b/app/models/romaneio_veiculo.rb
new file mode 100644
index 0000000..e674ea1
--- /dev/null
+++ b/app/models/romaneio_veiculo.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index be5d55c..967d352 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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]
diff --git a/app/policies/romaneio_policy.rb b/app/policies/romaneio_policy.rb
new file mode 100644
index 0000000..59faf07
--- /dev/null
+++ b/app/policies/romaneio_policy.rb
@@ -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
diff --git a/app/services/pdf/base_pdf.rb b/app/services/pdf/base_pdf.rb
index d576cfc..c5ec729 100644
--- a/app/services/pdf/base_pdf.rb
+++ b/app/services/pdf/base_pdf.rb
@@ -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
diff --git a/app/services/pdf/fonte_utf8.rb b/app/services/pdf/fonte_utf8.rb
new file mode 100644
index 0000000..2d87050
--- /dev/null
+++ b/app/services/pdf/fonte_utf8.rb
@@ -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
diff --git a/app/services/pdf/romaneio_pdf.rb b/app/services/pdf/romaneio_pdf.rb
new file mode 100644
index 0000000..7fb43e4
--- /dev/null
+++ b/app/services/pdf/romaneio_pdf.rb
@@ -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
diff --git a/app/services/romaneios/enriquecimento_gade.rb b/app/services/romaneios/enriquecimento_gade.rb
new file mode 100644
index 0000000..aefe627
--- /dev/null
+++ b/app/services/romaneios/enriquecimento_gade.rb
@@ -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
diff --git a/app/services/romaneios/importador.rb b/app/services/romaneios/importador.rb
new file mode 100644
index 0000000..7dac8d8
--- /dev/null
+++ b/app/services/romaneios/importador.rb
@@ -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
diff --git a/app/services/romaneios/normalizador.rb b/app/services/romaneios/normalizador.rb
new file mode 100644
index 0000000..a331063
--- /dev/null
+++ b/app/services/romaneios/normalizador.rb
@@ -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
diff --git a/app/services/romaneios/plano_do_dia.rb b/app/services/romaneios/plano_do_dia.rb
new file mode 100644
index 0000000..4189bf9
--- /dev/null
+++ b/app/services/romaneios/plano_do_dia.rb
@@ -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
diff --git a/app/services/romaneios/plano_planilha.rb b/app/services/romaneios/plano_planilha.rb
new file mode 100644
index 0000000..fef63f4
--- /dev/null
+++ b/app/services/romaneios/plano_planilha.rb
@@ -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
diff --git a/app/services/simpli_route/client.rb b/app/services/simpli_route/client.rb
index 5f59e78..e769f06 100644
--- a/app/services/simpli_route/client.rb
+++ b/app/services/simpli_route/client.rb
@@ -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.
diff --git a/app/views/admin/romaneios/_avisos.html.erb b/app/views/admin/romaneios/_avisos.html.erb
new file mode 100644
index 0000000..9f10cdc
--- /dev/null
+++ b/app/views/admin/romaneios/_avisos.html.erb
@@ -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? %>
+
+ ⚠️
+
+ A operação <%= @romaneio.operacao_label %> não tem a coluna
+ STATUS, então não dá para saber quem é paciente novo — a coluna
+ APARELHO 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.
+
+
+<% end %>
+
+<% if @colunas_ausentes.include?('telefones') && @romaneio.operacao_tabela.present? %>
+
+ ☎️
+
+ A operação <%= @romaneio.operacao_label %> não tem a coluna
+ TELEFONES. O que aparece na tabela veio do “TEL:” das anotações do
+ plano, que nem sempre traz todos os números.
+
+
+<% end %>
+
+<% if @romaneio.operacao_tabela.blank? %>
+
+ ℹ️
+
+ Este romaneio foi importado sem operação vinculada: a coluna APARELHO
+ não é preenchida sozinha e o telefone vem só das anotações do plano.
+
+
+<% end %>
+
+<% if multifolha.any? %>
+
+ 🖨️
+
+ <% multifolha.each do |veiculo, total| %>
+ <%= veiculo %> 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.
+
+
+<% end %>
diff --git a/app/views/admin/romaneios/_celula.html.erb b/app/views/admin/romaneios/_celula.html.erb
new file mode 100644
index 0000000..ad83ff8
--- /dev/null
+++ b/app/views/admin/romaneios/_celula.html.erb
@@ -0,0 +1,13 @@
+<%# Uma célula editável + o marcador de "editado". %>
+
diff --git a/app/views/admin/romaneios/_controles.html.erb b/app/views/admin/romaneios/_controles.html.erb
new file mode 100644
index 0000000..1186724
--- /dev/null
+++ b/app/views/admin/romaneios/_controles.html.erb
@@ -0,0 +1,55 @@
+<%# Seletor de veículo + os dois campos de cabeçalho da folha. %>
+
+
+
+
+ <%# ‹ › 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. %>
+
+
+
+
diff --git a/app/views/admin/romaneios/_marcador.html.erb b/app/views/admin/romaneios/_marcador.html.erb
new file mode 100644
index 0000000..c737be7
--- /dev/null
+++ b/app/views/admin/romaneios/_marcador.html.erb
@@ -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. %>
+
+ <% if linha.editado?(campo) %>
+
+ <% end %>
+
diff --git a/app/views/admin/romaneios/_removidas.html.erb b/app/views/admin/romaneios/_removidas.html.erb
new file mode 100644
index 0000000..c1234b9
--- /dev/null
+++ b/app/views/admin/romaneios/_removidas.html.erb
@@ -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? %>
+
+
+ <%= linhas.size %> parada(s) saíram do plano
+
+
+ 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.
+
+<% end %>
diff --git a/app/views/admin/romaneios/_tabela.html.erb b/app/views/admin/romaneios/_tabela.html.erb
new file mode 100644
index 0000000..815470d
--- /dev/null
+++ b/app/views/admin/romaneios/_tabela.html.erb
@@ -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.
+
+ por célula, e não contenteditable: colar do Excel num contenteditable
+ traz HTML junto. %>
+
+
+
<%= linhas.size %> paradas
+ Alterações salvam sozinhas
+
+
+
+
+
+
+
#
+
Nota fiscal
+
Nome
+
Endereço
+
Aparelho
+
Telefones
+
+
+
+ <% linhas.each_with_index do |linha, i| %>
+
+
<%= i + 1 %>
+ <% %w[nota_fiscal nome endereco].each do |campo| %>
+
+ <%= render 'celula', linha: linha, campo: campo %>
+
+ <% 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. %>
+
+ Monta o "CONTROLE DE ENTREGA" — uma folha por veículo, assinada pelo motorista na retirada
+
+
+
+ <%= render 'shared/flash' %>
+
+
+ ℹ️
+
+ A coluna APARELHO é marcada sozinha para quem está como
+ NOVO na operação do mês, e o telefone vem da própria
+ operação — não é preciso subir uma segunda planilha. Tudo continua editável na tela
+ antes de imprimir.
+
+
+
+ <%# Reserva: o plano do dia pode ainda não estar publicado. %>
+
+
+ Ou enviar a planilha do plano (.xlsx)
+
+
+
+
+
+
+ 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.
+
+
+
+ <% end %>
+
+
+ <%# Romaneios já importados %>
+
+ <% if @romaneios.empty? %>
+
+
Nenhum romaneio ainda.
+
Escolha a data do plano acima e clique em “Buscar no SimpliRoute”.
+
+ <% else %>
+
+
+
+
Data
+
Plano
+
Operação
+
Veículos
+
Origem
+
+
+
+
+ <% @romaneios.each do |r| %>
+
+
<%= l r.planned_date, format: '%d/%m/%Y' %>
+
<%= r.rotulo_plano.presence || '—' %>
+
<%= r.operacao_label || '—' %>
+
<%= r.romaneio_veiculos.size %>
+
+ <%= r.origem %>
+
+
+ <%= 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 , 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 %>
+
+
+ <% end %>
+
+
+ <% end %>
+
+
diff --git a/app/views/admin/romaneios/show.html.erb b/app/views/admin/romaneios/show.html.erb
new file mode 100644
index 0000000..5ad792e
--- /dev/null
+++ b/app/views/admin/romaneios/show.html.erb
@@ -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