Implantação da criação de romaneios

This commit is contained in:
2026-08-27 19:51:38 -03:00
parent b3464e3acd
commit e2b74068d5
13 changed files with 497 additions and 39 deletions

View File

@@ -25,7 +25,14 @@ TZ=America/Sao_Paulo
PORTA_APP=3001 PORTA_APP=3001
# ── Banco de dados (seu PostgreSQL já existente) ───────────── # ── Banco de dados (seu PostgreSQL já existente) ─────────────
# Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL # ⚠️ SÓ O `DATABASE_URL` É LIDO. Este projeto não tem config/database.yml — o
# Rails monta a conexão a partir desta URL, e nenhum código lê DB_HOST,
# DB_NAME, DB_USER ou DB_PASSWORD (elas ficam abaixo como documentação dos
# valores que compõem a URL). Se editar só as DB_*, nada muda.
# ⚠️ Senha com caractere especial precisa ser escapada na URL:
# @ → %40 # → %23 / → %2F : → %3A
# Deixar o valor de exemplo aqui derruba o boot com
# "URI::InvalidURIError: ... SEU_IP".
DATABASE_URL=postgresql://postgres:senha_segura@SEU_IP:5432/logistica_db DATABASE_URL=postgresql://postgres:senha_segura@SEU_IP:5432/logistica_db
DB_HOST=SEU_IP # ex: 192.168.1.100 ou db.gade.com.br DB_HOST=SEU_IP # ex: 192.168.1.100 ou db.gade.com.br
DB_PORT=5432 DB_PORT=5432

View File

@@ -234,6 +234,24 @@ class Admin::RomaneiosController < ApplicationController
return nil return nil
end end
# Sem operação escolhida, tenta deduzi-la do NOME DO PLANO ("ENTREGAS EMAD
# 09.2026" → gade_entregas_emad_set_2026).
#
# POR QUE ISSO É IMPORTANTE E NÃO CONVENIÊNCIA: é a operação que diz quem é
# paciente NOVO. Importar sem ela produz um romaneio de aparência normal com a
# coluna APARELHO vazia para TODOS — o erro só aparece no papel, com aparelho
# não entregue. A operação não é campo opcional na prática; era só difícil de
# acertar, porque o nome do plano e o nome da tabela nunca são iguais.
#
# Empate/nenhum casamento devolve nil e segue sem operação: vincular a errada
# preencheria APARELHO e TELEFONE com dados de OUTRO mês, sem ninguém
# desconfiar. A tela avisa quando ficou sem.
@operacao_deduzida = nil
if tabela.blank? && params[:rotulo_plano].present?
tabela = Romaneios::CasadorDeOperacao.casar(params[:rotulo_plano]).to_s
@operacao_deduzida = tabela.presence
end
data = Date.parse(data) data = Date.parse(data)
Romaneio.find_or_create_by!(planned_date: data, operacao_tabela: tabela) do |r| 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.rotulo_plano = params[:rotulo_plano].presence || Romaneio.rotulo_sugerido(tabela, data)
@@ -247,8 +265,18 @@ class Admin::RomaneiosController < ApplicationController
def importar(linhas, origem:) def importar(linhas, origem:)
resultado = aplicar(linhas, origem: origem) resultado = aplicar(linhas, origem: origem)
auditar!(:criar, @romaneio, dados_novos: resultado.to_h) auditar!(:criar, @romaneio, dados_novos: resultado.to_h)
# Dizer que a operação foi deduzida, e qual: dedução silenciosa que acerta é
# invisível, mas dedução silenciosa que erra é indefensável — o operador
# precisa ver de qual mês vieram os dados de APARELHO.
aviso = if @operacao_deduzida
" Operação #{Operacao.label(@operacao_deduzida)} vinculada pelo nome do plano."
else
''
end
redirect_to admin_romaneio_path(@romaneio), redirect_to admin_romaneio_path(@romaneio),
notice: "Plano importado — #{resultado.resumo}." notice: "Plano importado — #{resultado.resumo}.#{aviso}"
end end
def aplicar(linhas, origem:) def aplicar(linhas, origem:)

View File

@@ -10,7 +10,8 @@ import { Controller } from "@hotwired/stimulus"
// plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com // plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com
// escrita por campo. // escrita por campo.
export default class extends Controller { export default class extends Controller {
static targets = ["celula", "preview", "itemVeiculo", "motorista", "rotulo", "status", "linha", "busca", "contador"] static targets = ["celula", "preview", "itemVeiculo", "motorista", "rotulo", "status", "linha", "busca",
"contador", "overlay", "caixaPreview", "tituloPreview"]
static values = { url: String, pdfUrl: String, veiculo: String } static values = { url: String, pdfUrl: String, veiculo: String }
// Recarregar o PDF a cada tecla travaria a tela; 600ms depois do último save é o // Recarregar o PDF a cada tecla travaria a tela; 600ms depois do último save é o
@@ -19,6 +20,10 @@ export default class extends Controller {
connect() { connect() {
this.timerPreview = null this.timerPreview = null
// A prévia nasce fechada e DESATUALIZADA: o PDF só é gerado na primeira vez
// que alguém pede para ver. Antes o iframe era montado a cada carregamento da
// tela e gerava um PDF que muitas vezes ninguém olhava.
this.previewDesatualizado = true
// Com 72 carros a lista rola; sem isto, ao trocar de veículo o selecionado // Com 72 carros a lista rola; sem isto, ao trocar de veículo o selecionado
// pode nascer fora da área visível e parece que a seleção não pegou. // pode nascer fora da área visível e parece que a seleção não pegou.
const atual = this.itemVeiculoTargets.find((a) => a.dataset.atual === "1") const atual = this.itemVeiculoTargets.find((a) => a.dataset.atual === "1")
@@ -124,12 +129,49 @@ export default class extends Controller {
this.buscaTarget.focus() this.buscaTarget.focus()
} }
atualizarPreview() { // ── Prévia (sobreposição) ───────────────────────────────────────────────
abrirPreview() {
if (!this.hasOverlayTarget) return
this.overlayTarget.hidden = false
// Trava o scroll do fundo: rolar a lista atrás de um modal aberto faz o
// operador perder o lugar quando fecha.
document.body.style.overflow = "hidden"
if (this.previewDesatualizado) this.carregarPreview()
}
fecharPreview() {
if (!this.hasOverlayTarget || this.overlayTarget.hidden) return
this.overlayTarget.hidden = true
document.body.style.overflow = ""
}
// Clique no fundo escuro fecha; clique DENTRO da caixa não — senão mexer no
// PDF fecharia a prévia sem querer.
fecharPorFora(event) {
if (this.hasCaixaPreviewTarget && this.caixaPreviewTarget.contains(event.target)) return
this.fecharPreview()
}
carregarPreview() {
if (!this.hasPreviewTarget) return if (!this.hasPreviewTarget) return
// Cache-bust em vez de reload(): reload reinicializa o visualizador de PDF do // Cache-bust em vez de reload(): reload reinicializa o visualizador de PDF do
// navegador e a tela pisca a cada salvamento. // navegador e a tela pisca a cada salvamento.
this.previewTarget.src = this.previewTarget.src =
`${this.pdfUrlValue}?veiculo=${encodeURIComponent(this.veiculoValue)}&t=${Date.now()}` `${this.pdfUrlValue}?veiculo=${encodeURIComponent(this.veiculoValue)}&t=${Date.now()}`
this.previewDesatualizado = false
}
// Chamada depois de cada gravação. Com a prévia FECHADA não gera PDF nenhum —
// só marca que o próximo "Ver prévia" precisa buscar de novo.
atualizarPreview() {
if (!this.hasOverlayTarget || this.overlayTarget.hidden) {
this.previewDesatualizado = true
return
}
this.carregarPreview()
} }
// ── Infra ─────────────────────────────────────────────────────────────── // ── Infra ───────────────────────────────────────────────────────────────

View File

@@ -121,6 +121,14 @@ class Operacao
} }
end end
# Número do mês a partir de um token do nome ("ago", "agosto", "AGOSTO").
# Público de propósito: Romaneios::CasadorDeOperacao precisa da MESMA tabela de
# meses para casar o nome do plano do SimpliRoute com a tabela da operação, e
# duplicar esse mapa seria criar uma segunda verdade que sai de sincronia.
def mes_numero(token)
MESES[token.to_s.downcase]&.first
end
private private
# Extrai { titulo:, item:, ordem: } do nome da tabela. # Extrai { titulo:, item:, ordem: } do nome da tabela.

View File

@@ -45,6 +45,23 @@ class Romaneio < ApplicationRecord
@contagem_por_veiculo ||= linhas_vigentes.reorder(nil).group(:veiculo).count @contagem_por_veiculo ||= linhas_vigentes.reorder(nil).group(:veiculo).count
end end
# { novos: 184, recorrentes: 1863 } — quantos pacientes recebem APARELHO.
#
# POR QUE APARECE NA TELA: é a conferência que o programa antigo fazia com uma
# caixa de diálogo ("184 entrega(s) marcada(s) como NOVO") logo depois de
# carregar o status. Sem esse número, um romaneio importado SEM operação
# vinculada — em que ninguém é marcado como novo — parece idêntico a um
# importado certo, e o erro só aparece no papel, com aparelho não entregue.
# "0 novos" na tela é a pergunta que o operador precisa se fazer.
def contagem_aparelho
@contagem_aparelho ||= begin
grupos = linhas_vigentes.reorder(nil).group(:aparelho).count
total = grupos.values.sum
novos = grupos.sum { |valor, qtd| valor.to_s.strip.casecmp('SIM').zero? ? qtd : 0 }
{ novos: novos, recorrentes: total - novos }
end
end
def folhas_de(veiculo) def folhas_de(veiculo)
total = contagem_por_veiculo[veiculo].to_i total = contagem_por_veiculo[veiculo].to_i
return 1 if total.zero? return 1 if total.zero?

View File

@@ -0,0 +1,130 @@
# app/services/romaneios/casador_de_operacao.rb
#
# Casa o NOME DO PLANO do SimpliRoute com a tabela da operação do mês.
#
# ── POR QUE EXISTE ──────────────────────────────────────────────────────────
# A operação não trabalha por data — ela identifica o plano pelo nome ("ENTREGAS
# EMAD 09.2026", "UBS SUL E LESTE AGOSTO 2026"). E é a operação do mês que diz
# quem é paciente NOVO: sem ela vinculada, `Romaneios::EnriquecimentoGade` nem
# roda e a coluna APARELHO sai vazia para todos — um romaneio que parece certo e
# só revela o erro no papel, com aparelho não entregue.
#
# A API pública do SimpliRoute NÃO tem endpoint que liste planos (a documentação
# só expõe criação, /v1/plans/{data}/vehicles/ e /v1/plans/routes/{id}/visits/);
# a lista que aparece na tela deles é da interface web. Então o nome chega aqui
# DIGITADO ou COLADO pelo operador, e o casamento tem que aguentar variação.
#
# ── AS DIFERENÇAS SÃO SISTEMÁTICAS, NÃO ALEATÓRIAS ──────────────────────────
# plano "UBS SUL E LESTE AGOSTO 2026" x tabela ubs_sul_leste_ago_2026
# ↑ conectivo "E" ↑ mês abreviado
# plano "ENTREGAS EMAD 09.2026" x tabela emad_set_2026
# ↑ prefixo genérico ↑ mês NUMÉRICO
# Por isso a comparação é por CONJUNTO DE PALAVRAS + (mês, ano), e não por
# igualdade de texto — que erraria em todos os casos reais acima.
module Romaneios
class CasadorDeOperacao
# Palavras que aparecem no nome do plano e nunca no nome da tabela. "ENTREGAS"
# entra aqui porque é o prefixo genérico que o cliente usa nos planos; o
# prefixo das tabelas (`gade_entregas_`) já é removido antes.
RUIDO = %w[entregas entrega de da do das dos e o a as os plano planos].freeze
Resultado = Struct.new(:tabela, :confianca, :motivo, keyword_init: true)
def self.casar(nome, tabelas: nil)
new(nome, tabelas: tabelas).casar
end
def initialize(nome, tabelas: nil)
@nome = nome.to_s
@tabelas = tabelas
end
# Devolve o nome da tabela, ou nil quando não dá para ter certeza.
#
# ⚠️ Empate devolve NIL de propósito. Vincular a operação errada é pior do que
# não vincular: com a errada, APARELHO e TELEFONE saem preenchidos com dados
# de OUTRO mês e ninguém desconfia; sem nenhuma, a tela avisa que está sem
# operação e o operador escolhe.
def casar
resultado.tabela
end
def resultado
@resultado ||= calcular
end
private
def calcular
alvo = decompor(@nome)
return Resultado.new(confianca: :nenhuma, motivo: 'nome vazio') if alvo[:palavras].empty?
candidatas = (@tabelas || Operacao.nomes_validos).map do |tabela|
[tabela, pontuar(alvo, decompor(Operacao.label(tabela)))]
end.reject { |_, ponto| ponto.nil? }
return Resultado.new(confianca: :nenhuma, motivo: 'nenhuma operação com o mesmo mês/ano') if candidatas.empty?
melhor = candidatas.max_by(&:last)
empate = candidatas.count { |_, ponto| ponto == melhor.last }
if empate > 1
return Resultado.new(confianca: :ambigua,
motivo: "#{empate} operações batem igualmente — escolha na lista")
end
Resultado.new(tabela: melhor.first, confianca: :alta, motivo: 'mês, ano e palavras batem')
end
# "UBS SUL E LESTE AGOSTO 2026" -> { palavras: #{ubs sul leste}, mes: 8, ano: 2026 }
def decompor(texto)
tokens = normalizar(texto)
ano = tokens.find { |t| t.match?(/\A(19|20)\d{2}\z/) }
mes = mes_de(tokens, ano)
palavras = tokens - [ano, mes[:token]].compact
palavras = palavras.reject { |t| RUIDO.include?(t) }
{ palavras: palavras.to_set, mes: mes[:numero], ano: ano&.to_i }
end
# Aceita "agosto", "ago" e o mês NUMÉRICO de "09.2026" — este último só quando
# vem colado ao ano, senão o "3" de "AVULSAS 3 EMAD" viraria março.
def mes_de(tokens, ano)
por_nome = tokens.find { |t| Operacao.mes_numero(t) }
return { token: por_nome, numero: Operacao.mes_numero(por_nome) } if por_nome
return { token: nil, numero: nil } unless ano
idx = tokens.index(ano)
anterior = idx&.positive? ? tokens[idx - 1] : nil
if anterior&.match?(/\A\d{1,2}\z/) && (1..12).cover?(anterior.to_i)
{ token: anterior, numero: anterior.to_i }
else
{ token: nil, numero: nil }
end
end
def normalizar(texto)
ActiveSupport::Inflector.transliterate(texto.to_s)
.downcase
.split(/[^a-z0-9]+/) # "09.2026" e "EMAD/SUDESTE" quebram aqui
.reject(&:empty?)
end
# nil = descartada. Mês e ano são ELIMINATÓRIOS: é o que impede "EMAD 09.2026"
# de casar com a tabela de agosto, que tem exatamente as mesmas palavras.
def pontuar(alvo, candidata)
return nil if alvo[:mes] && candidata[:mes] && alvo[:mes] != candidata[:mes]
return nil if alvo[:ano] && candidata[:ano] && alvo[:ano] != candidata[:ano]
return nil if alvo[:palavras].empty? || candidata[:palavras].empty?
comuns = (alvo[:palavras] & candidata[:palavras]).size
return nil if comuns.zero?
sobrando = (alvo[:palavras] ^ candidata[:palavras]).size
# Palavra em comum soma 2; palavra que só um lado tem tira 1. "UBS SUL LESTE"
# contra "UBS SUL" ainda casa, mas perde para "UBS SUL LESTE" exato.
(comuns * 2) - sobrando
end
end
end

View File

@@ -63,11 +63,17 @@
</div> </div>
<div class="space-y-2 pt-1"> <div class="space-y-2 pt-1">
<%# Prévia sob demanda: o PDF só é gerado quando alguém pede para ver. %>
<button type="button" data-action="romaneio#abrirPreview"
class="w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#1a1a1a]
hover:bg-[#242424] text-white border border-white/10 rounded-xl">
<%= icone :visualizar, cor: nil %> Ver prévia do PDF
</button>
<%= link_to pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo), <%= link_to pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo),
target: '_blank', rel: 'noopener', target: '_blank', rel: 'noopener',
class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#1a1a1a] class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#1a1a1a]
hover:bg-[#242424] text-white border border-white/10 rounded-xl' do %> hover:bg-[#242424] text-white border border-white/10 rounded-xl' do %>
<%= icone :visualizar, cor: nil %> PDF deste veículo <%= icone :baixar, cor: nil %> PDF deste veículo
<% end %> <% end %>
<%= link_to pdf_admin_romaneio_path(@romaneio), <%= link_to pdf_admin_romaneio_path(@romaneio),
class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#f97316] class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#f97316]

View File

@@ -73,7 +73,10 @@
<select name="operacao_tabela" <select name="operacao_tabela"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white 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]"> focus:outline-none focus:border-orange-500 min-h-[48px]">
<option value="">Sem operação — só o que o plano trouxer</option> <%# "Deixe em branco" e não "Sem operação": deixar vazio agora tenta
deduzir a operação pelo nome do PLANO, e é esse o caminho normal.
Escolher aqui é o override para quando a dedução não acerta. %>
<option value="">Deduzir pelo nome do plano</option>
<% @operacoes_agrupadas.each do |titulo, ops| %> <% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>"> <optgroup label="<%= titulo %>">
<% ops.each do |op| %> <% ops.each do |op| %>
@@ -88,9 +91,13 @@
<label class="block text-sm font-medium text-gray-300 mb-1.5"> <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> PLANO <span class="text-gray-500 font-normal">(canto direito do PDF)</span>
</label> </label>
<input type="text" name="rotulo_plano" placeholder="Ex.: EMAD 09.2026" <input type="text" name="rotulo_plano" placeholder="Ex.: ENTREGAS EMAD 09.2026"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white 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]"> placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
<p class="text-xs text-gray-500 mt-1.5">
Cole o nome do plano como está no SimpliRoute — é por ele que a
operação do mês (quem é <strong>NOVO</strong>) é encontrada.
</p>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
<%# app/views/admin/romaneios/show.html.erb <%# app/views/admin/romaneios/show.html.erb
Editor do romaneio: tabela editável à esquerda, o PDF REAL à direita. Editor do romaneio: veículos à esquerda, tabela editável à direita, e o PDF
REAL numa sobreposição aberta sob demanda.
A prévia é um <iframe> de mesma origem, e isso não é preferência: o CSP deste 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/ app está em modo enforcing com `object_src :none` (config/initializers/
@@ -12,7 +13,7 @@
data-romaneio-pdf-url-value="<%= pdf_admin_romaneio_path(@romaneio) %>" data-romaneio-pdf-url-value="<%= pdf_admin_romaneio_path(@romaneio) %>"
data-romaneio-veiculo-value="<%= @veiculo %>"> data-romaneio-veiculo-value="<%= @veiculo %>">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"> <div class="flex flex-col sm:flex-row sm:flex-wrap sm:items-start sm:justify-between gap-3">
<div> <div>
<h1 class="text-2xl font-bold text-white"> <h1 class="text-2xl font-bold text-white">
Romaneio de <%= l @romaneio.planned_date, format: '%d/%m/%Y' %> Romaneio de <%= l @romaneio.planned_date, format: '%d/%m/%Y' %>
@@ -25,6 +26,22 @@
<%= "em #{l @romaneio.importado_em, format: '%d/%m %H:%M'}" if @romaneio.importado_em %> <%= "em #{l @romaneio.importado_em, format: '%d/%m %H:%M'}" if @romaneio.importado_em %>
</p> </p>
</div> </div>
<%# NOVOS x RECORRENTES: o mesmo aviso que o programa antigo dava numa caixa
de diálogo depois de aplicar o status. Fica ao lado do título porque é a
primeira coisa a conferir — "0 novos" quase sempre significa importação
sem operação vinculada, e o aviso azul abaixo explica o que fazer. %>
<% contagem = @romaneio.contagem_aparelho %>
<div class="flex items-center gap-2 text-sm">
<span class="px-3 py-1.5 rounded-xl border
<%= contagem[:novos].positive? ? 'bg-green-600/15 border-green-500/40 text-green-300'
: 'bg-amber-900/20 border-amber-500/40 text-amber-200' %>">
<strong><%= contagem[:novos] %></strong> com aparelho (NOVOS)
</span>
<span class="px-3 py-1.5 rounded-xl bg-white/5 border border-white/10 text-gray-300">
<strong><%= contagem[:recorrentes] %></strong> recorrentes
</span>
</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<%= link_to rotulo(:baixar, 'Baixar todos'), <%= link_to rotulo(:baixar, 'Baixar todos'),
pdf_admin_romaneio_path(@romaneio), pdf_admin_romaneio_path(@romaneio),
@@ -51,46 +68,55 @@
<% else %> <% else %>
<%= render 'avisos' %> <%= render 'avisos' %>
<%# Três zonas, como no programa que a operação já usava: VEÍCULOS à esquerda, <%# Duas zonas na tela: VEÍCULOS e a edição. A prévia saiu do fluxo e virou
edição no meio, PDF de verdade à direita. Em telas médias a prévia desce sobreposição sob demanda (botão "Ver prévia") — antes o <iframe> era montado
para baixo (duas colunas); no celular vira uma coluna só. %> em TODO carregamento, e cada montagem gerava um PDF no servidor só para
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)] ficar num canto que o operador nem sempre olha. Agora o PDF só é gerado
xl:grid-cols-[minmax(0,260px)_minmax(0,1fr)_minmax(0,520px)] gap-5 items-start"> quando alguém pede para ver. %>
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)] gap-5 items-start">
<div class="lg:sticky lg:top-4"> <div class="lg:sticky lg:top-4">
<%= render 'veiculos' %> <%= render 'veiculos' %>
</div> </div>
<div class="space-y-4 lg:col-span-1"> <div class="space-y-4">
<%= render 'controles' %> <%= render 'controles' %>
<%= render 'tabela', linhas: @linhas %> <%= render 'tabela', linhas: @linhas %>
<%= render 'removidas', linhas: @removidas %> <%= render 'removidas', linhas: @removidas %>
</div> </div>
</div>
<%# Prévia — no celular vira um bloco recolhido: 6 colunas em tela pequena <%# ── Prévia em sobreposição ──────────────────────────────────────────────
exigiriam zoom, e o que importa ali é editar, não conferir o layout. %> Fica FORA da grade, ocupando a tela inteira quando aberta. `hidden` no
<div class="lg:col-span-2 xl:col-span-1 xl:sticky xl:top-4"> markup e o src só preenchido na abertura: um iframe com src carrega
<details class="xl:hidden bg-[#111] border border-white/5 rounded-2xl p-4 mb-4"> mesmo escondido, o que anularia a economia.
<summary class="cursor-pointer text-white font-medium select-none min-h-[44px] flex items-center">
<%= icone :visualizar %> Ver prévia do PDF <iframe> e não <embed>/<object>: o CSP está em enforcing com
</summary> `object_src :none`, e sem `frame_src` ele cai em `default_src :self` —
<%= link_to 'Abrir o PDF deste veículo', o iframe de mesma origem passa, os outros dois não. %>
<div data-romaneio-target="overlay" hidden
data-action="click->romaneio#fecharPorFora keydown.esc@window->romaneio#fecharPreview"
class="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6
bg-black/70 backdrop-blur-sm">
<div class="w-full max-w-6xl h-[92vh] flex flex-col gap-3"
data-romaneio-target="caixaPreview">
<div class="flex items-center justify-between gap-3">
<h2 class="text-white font-semibold">
Prévia — <span data-romaneio-target="tituloPreview"><%= @veiculo %></span>
</h2>
<div class="flex items-center gap-2">
<%= link_to 'Abrir em outra aba',
pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo), pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo),
class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %> target: '_blank', rel: 'noopener',
</details> class: 'text-sm text-gray-300 hover:text-white px-3 py-2 rounded-lg hover:bg-white/10' %>
<button type="button" data-action="romaneio#fecharPreview"
<div class="hidden xl:block bg-[#111] border border-white/5 rounded-2xl p-4"> aria-label="Fechar prévia"
<div class="flex items-center justify-between mb-3"> class="min-w-[48px] min-h-[48px] rounded-xl bg-[#1a1a1a] border border-white/10
<h2 class="text-white font-semibold text-sm">Prévia — <%= @veiculo %></h2> text-white hover:bg-[#242424]">✕</button>
<button type="button" data-action="romaneio#atualizarPreview" </div>
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> </div>
<iframe data-romaneio-target="preview" <iframe data-romaneio-target="preview"
src="<%= pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo) %>" class="flex-1 w-full rounded-xl border border-white/10 bg-white"
class="w-full h-[78vh] rounded-xl border border-white/10 bg-white" title="Pré-visualização do romaneio"></iframe>
title="Pré-visualização do romaneio do veículo <%= @veiculo %>"></iframe>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@@ -40,6 +40,30 @@ if ! bundle check >/dev/null 2>&1; then
fi fi
# ── 4. Migrations ──────────────────────────────────────────────────────────── # ── 4. Migrations ────────────────────────────────────────────────────────────
# ANTES de tentar, confere o DATABASE_URL. Este projeto NÃO tem
# config/database.yml: a conexão inteira sai dessa única variável (as DB_HOST,
# DB_USER etc. do .env não são lidas por ninguém — são só documentação).
#
# POR QUE ESTA CHECAGEM EXISTE: em 27/08/2026 o .env foi recriado a partir do
# .env.example e o DATABASE_URL ficou com o valor de exemplo. O resultado foram
# 10 tentativas cuspindo 40 linhas de backtrace até uma
# `URI::InvalidURIError: ... SEU_IP` no fim — ilegível no log do Container
# Manager. Placeholder e URL vazia não são "banco fora do ar": nenhuma espera
# resolve, então falha na hora e diz o que fazer.
if [ -z "${DATABASE_URL:-}" ]; then
log "ERRO: DATABASE_URL vazia. Sem config/database.yml, é ela que define o banco."
log " Formato: postgresql://USUARIO:SENHA@HOST:5432/NOME_DO_BANCO"
exit 1
fi
case "$DATABASE_URL" in
*SEU_IP*|*senha_segura*|*SEU_HOST*)
log "ERRO: DATABASE_URL ainda está com o valor de EXEMPLO do .env.example:"
log " $(printf '%s' "$DATABASE_URL" | sed 's|://[^@]*@|://***:***@|')"
log " Preencha com o IP, o usuário e a senha reais do PostgreSQL."
exit 1
;;
esac
# O PostgreSQL é EXTERNO ao compose: num reboot do servidor o Rails pode subir # O PostgreSQL é EXTERNO ao compose: num reboot do servidor o Rails pode subir
# antes do banco aceitar conexão. Sem a espera abaixo o container sairia com # antes do banco aceitar conexão. Sem a espera abaixo o container sairia com
# erro e o `restart: unless-stopped` ficaria reiniciando em loop — parecendo # erro e o `restart: unless-stopped` ficaria reiniciando em loop — parecendo

View File

@@ -146,4 +146,73 @@ puts ' Compare com o espelho, no console do app:'
puts " Entrega.da_conta_gade.no_periodo(Date.parse('#{data}'), Date.parse('#{data}'))" \ puts " Entrega.da_conta_gade.no_periodo(Date.parse('#{data}'), Date.parse('#{data}'))" \
'.distinct.count(:vehicle)' '.distinct.count(:vehicle)'
puts puts
# ── 4. O plano tem NOME? ────────────────────────────────────────────────────
# POR QUE ISTO IMPORTA: a operação NÃO trabalha por data — ela identifica o
# plano pelo NOME ("EMAD 09.2026" e afins), que é inclusive o texto impresso no
# canto do romaneio. Hoje a tela pede a data porque é o único filtro que
# sabemos que a API aceita (/v1/routes/visits/?planned_date=). Para trocar por
# uma BUSCA POR NOME é preciso primeiro descobrir se existe endpoint de planos
# e qual campo carrega o nome — é o que esta seção responde.
#
# As URLs abaixo são CANDIDATAS: a única rota /v1/plans/ que o app já usa é
# /v1/plans/visits/<id>/detail/, então sabemos que o namespace existe, mas não
# se ele lista planos. 404/403 aqui é resposta válida — significa "não dá por
# esse caminho", e a busca por nome teria que ser feita filtrando as visitas.
puts
puts '[4] procurando um endpoint de PLANOS (para buscar por nome em vez de data)'
CHAVES_NOME = /name|title|label|descri|plan/i.freeze
candidatas = [
'/v1/plans/',
"/v1/plans/?planned_date=#{data}",
'/v1/routes/plans/',
"/v1/routes/plans/?planned_date=#{data}"
]
achou_algum = false
candidatas.each do |caminho|
codigo, corpo, seg = get(caminho)
puts " GET #{caminho}"
puts " HTTP #{codigo} · #{corpo.bytesize} bytes · #{seg.round(2)}s"
dados = json(corpo)
# A API é Django REST: ora devolve lista, ora {"results": [...]}.
itens = dados.is_a?(Array) ? dados : (dados.is_a?(Hash) ? Array(dados['results']) : [])
if itens.empty?
puts " sem itens (#{corpo.slice(0, 120)})"
next
end
achou_algum = true
item = itens.first
next unless item.is_a?(Hash)
puts " itens: #{itens.size}"
puts " chaves: #{item.keys.sort.join(', ')}"
nomes = item.select { |k, v| k.to_s.match?(CHAVES_NOME) && !v.nil? && v.to_s.strip != '' }
if nomes.empty?
puts ' => nenhum campo de nome PREENCHIDO neste item'
else
puts ' => campos de nome preenchidos:'
nomes.each { |k, v| puts " #{k} = #{v.inspect.slice(0, 90)}" }
puts " => primeiros nomes: #{itens.filter_map { |i| i.is_a?(Hash) ? nomes.keys.filter_map { |k| i[k] }.first : nil }.first(5).inspect}"
end
end
# Plano B: se não há endpoint de planos, o nome pode vir na própria visita.
unless achou_algum
puts ' nenhum endpoint de planos respondeu com itens.'
puts ' procurando um campo de nome na VISITA (plano B — filtrar visitas pelo nome):'
nomes_visita = primeira.select { |k, v| k.to_s.match?(CHAVES_NOME) && !v.nil? && v.to_s.strip != '' }
if nomes_visita.empty?
puts ' (nenhum) => busca por nome NÃO é possível pela API; a data continua sendo'
puts ' o único filtro, e o nome fica só como rótulo do PDF.'
else
nomes_visita.each { |k, v| puts " #{k} = #{v.inspect.slice(0, 90)}" }
end
end
puts
puts 'Anote o resultado no cabeçalho deste arquivo antes de commitar.' puts 'Anote o resultado no cabeçalho deste arquivo antes de commitar.'

View File

@@ -8,8 +8,24 @@ Rails.application.configure do
# HTTPS obrigatório: redireciona HTTP→HTTPS e marca cookies como "Secure". # HTTPS obrigatório: redireciona HTTP→HTTPS e marca cookies como "Secure".
# assume_ssl faz o Rails confiar no TLS terminado no Cloudflare (que envia # assume_ssl faz o Rails confiar no TLS terminado no Cloudflare (que envia
# X-Forwarded-Proto=https), evitando loop de redirecionamento. # X-Forwarded-Proto=https), evitando loop de redirecionamento.
#
# ⚠️ EFEITO COLATERAL, e não é bug: com assume_ssl o Rails calcula
# `request.base_url` como https:// SEMPRE. Quem abre o sistema direto pelo
# IP em HTTP (http://100.75.222.23:3001) manda `Origin: http://...`, a
# verificação de CSRF compara os dois, não bate, e TODO POST volta 422
# ("A ação foi recusada por uma verificação de segurança") — enquanto os GETs
# passam e a tela parece normal. Aconteceu em 27/08/2026 ao importar o plano
# do romaneio. O uso normal é pelo domínio, onde Origin e base_url são https.
#
# O gate abaixo existe para DIAGNÓSTICO pelo IP: `FORCE_SSL=false` no .env
# devolve o acesso por HTTP puro. Sem a variável (ou com qualquer outro
# valor) o HTTPS continua obrigatório — o padrão seguro. Antes isto estava
# fixo aqui e o FORCE_SSL do .env, respeitado no application.rb, não tinha
# efeito nenhum em produção: as duas configurações se contradiziam.
unless ENV["FORCE_SSL"] == "false"
config.assume_ssl = true config.assume_ssl = true
config.force_ssl = true config.force_ssl = true
end
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
config.log_tags = [:request_id] config.log_tags = [:request_id]
config.logger = ActiveSupport::Logger.new(STDOUT).tap { |l| l.formatter = ::Logger::Formatter.new } config.logger = ActiveSupport::Logger.new(STDOUT).tap { |l| l.formatter = ::Logger::Formatter.new }

View File

@@ -0,0 +1,78 @@
require 'rails_helper'
# Os nomes aqui são os REAIS, lidos da tela de Planos do SimpliRoute
# (Imagens para correção/Imagem colada (11).png) e do cabeçalho do romaneio do
# programa antigo: "ENTREGAS EMAD 09.2026", "UBS SUL E LESTE AGOSTO 2026",
# "AVULSAS 3 EMAD/SUDESTE AGOSTO 2026". As três diferenças que quebram uma
# comparação de texto — conectivo "E", mês abreviado x por extenso, e mês
# NUMÉRICO ("09.2026") — estão todas cobertas.
RSpec.describe Romaneios::CasadorDeOperacao do
# Lista fixa: o teste é do CASAMENTO, não do catálogo do banco.
let(:tabelas) do
%w[
gade_entregas_emad_ago_2026
gade_entregas_emad_set_2026
gade_entregas_ubs_sul_leste_ago_2026
gade_entregas_ubs_norte_ago_2026
gade_entregas_ubs_oeste_ago_2026
gade_entregas_avulsas_3_emad_sudeste_ago_2026
gade_entregas_sudeste_julho_2026
]
end
def casar(nome) = described_class.casar(nome, tabelas: tabelas)
it 'casa o mês NUMÉRICO do plano com o mês abreviado da tabela' do
# "09" só é lido como mês por vir colado ao ano — ver o "3" de AVULSAS abaixo.
expect(casar('ENTREGAS EMAD 09.2026')).to eq('gade_entregas_emad_set_2026')
end
it 'casa o mês por extenso com a abreviação da tabela' do
expect(casar('EMAD SETEMBRO 2026')).to eq('gade_entregas_emad_set_2026')
expect(casar('EMAD AGOSTO 2026')).to eq('gade_entregas_emad_ago_2026')
end
it 'ignora o conectivo "E", que existe no plano e não no nome da tabela' do
expect(casar('UBS SUL E LESTE AGOSTO 2026')).to eq('gade_entregas_ubs_sul_leste_ago_2026')
end
it 'não confunde UBS de regiões diferentes no mesmo mês' do
expect(casar('UBS NORTE AGOSTO 2026')).to eq('gade_entregas_ubs_norte_ago_2026')
expect(casar('UBS OESTE AGOSTO 2026')).to eq('gade_entregas_ubs_oeste_ago_2026')
end
it 'trata a barra como separador e não lê o "3" de AVULSAS como março' do
expect(casar('AVULSAS 3 EMAD/SUDESTE AGOSTO 2026'))
.to eq('gade_entregas_avulsas_3_emad_sudeste_ago_2026')
end
it 'não depende de maiúsculas nem de acento' do
expect(casar('entregas emad 09.2026')).to eq('gade_entregas_emad_set_2026')
end
# ── O que NÃO pode casar ──────────────────────────────────────────────────
# Vincular a operação errada é pior do que não vincular: APARELHO e TELEFONE
# sairiam preenchidos com dados de OUTRO mês e ninguém desconfiaria.
it 'devolve nil quando o mês não tem tabela' do
expect(casar('EMAD DEZEMBRO 2026')).to be_nil
end
it 'devolve nil quando o mês bate mas a operação não existe' do
expect(casar('CARDIOLOGIA AGOSTO 2026')).to be_nil
end
it 'devolve nil com nome vazio' do
expect(casar('')).to be_nil
end
it 'devolve nil (e diz o motivo) quando duas operações empatam' do
resultado = described_class.new(
'EMAD AGOSTO 2026',
tabelas: %w[gade_entregas_emad_ago_2026 gade_entregas_emad_agosto_2026]
).resultado
expect(resultado.tabela).to be_nil
expect(resultado.confianca).to eq(:ambigua)
expect(resultado.motivo).to include('escolha na lista')
end
end