Implantação da criação de romaneios
This commit is contained in:
@@ -25,7 +25,14 @@ TZ=America/Sao_Paulo
|
||||
PORTA_APP=3001
|
||||
|
||||
# ── 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
|
||||
DB_HOST=SEU_IP # ex: 192.168.1.100 ou db.gade.com.br
|
||||
DB_PORT=5432
|
||||
|
||||
@@ -234,6 +234,24 @@ class Admin::RomaneiosController < ApplicationController
|
||||
return nil
|
||||
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)
|
||||
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)
|
||||
@@ -247,8 +265,18 @@ class Admin::RomaneiosController < ApplicationController
|
||||
def importar(linhas, origem:)
|
||||
resultado = aplicar(linhas, origem: origem)
|
||||
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),
|
||||
notice: "Plano importado — #{resultado.resumo}."
|
||||
notice: "Plano importado — #{resultado.resumo}.#{aviso}"
|
||||
end
|
||||
|
||||
def aplicar(linhas, origem:)
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Controller } from "@hotwired/stimulus"
|
||||
// plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com
|
||||
// escrita por campo.
|
||||
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 }
|
||||
|
||||
// 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() {
|
||||
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
|
||||
// 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")
|
||||
@@ -124,12 +129,49 @@ export default class extends Controller {
|
||||
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
|
||||
|
||||
// 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()}`
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -121,6 +121,14 @@ class Operacao
|
||||
}
|
||||
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
|
||||
|
||||
# Extrai { titulo:, item:, ordem: } do nome da tabela.
|
||||
|
||||
@@ -45,6 +45,23 @@ class Romaneio < ApplicationRecord
|
||||
@contagem_por_veiculo ||= linhas_vigentes.reorder(nil).group(:veiculo).count
|
||||
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)
|
||||
total = contagem_por_veiculo[veiculo].to_i
|
||||
return 1 if total.zero?
|
||||
|
||||
130
app/services/romaneios/casador_de_operacao.rb
Normal file
130
app/services/romaneios/casador_de_operacao.rb
Normal 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
|
||||
@@ -63,11 +63,17 @@
|
||||
</div>
|
||||
|
||||
<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),
|
||||
target: '_blank', rel: 'noopener',
|
||||
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 %>
|
||||
<%= icone :visualizar, cor: nil %> PDF deste veículo
|
||||
<%= icone :baixar, cor: nil %> PDF deste veículo
|
||||
<% end %>
|
||||
<%= link_to pdf_admin_romaneio_path(@romaneio),
|
||||
class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#f97316]
|
||||
|
||||
@@ -73,7 +73,10 @@
|
||||
<select name="operacao_tabela"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
<option value="">Sem operação — só o que o plano trouxer</option>
|
||||
<%# "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| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
@@ -88,9 +91,13 @@
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
PLANO <span class="text-gray-500 font-normal">(canto direito do PDF)</span>
|
||||
</label>
|
||||
<input type="text" name="rotulo_plano" placeholder="Ex.: EMAD 09.2026"
|
||||
<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
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<%# 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
|
||||
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-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>
|
||||
<h1 class="text-2xl font-bold text-white">
|
||||
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 %>
|
||||
</p>
|
||||
</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">
|
||||
<%= link_to rotulo(:baixar, 'Baixar todos'),
|
||||
pdf_admin_romaneio_path(@romaneio),
|
||||
@@ -51,46 +68,55 @@
|
||||
<% else %>
|
||||
<%= render 'avisos' %>
|
||||
|
||||
<%# Três zonas, como no programa que a operação já usava: VEÍCULOS à esquerda,
|
||||
edição no meio, PDF de verdade à direita. Em telas médias a prévia desce
|
||||
para baixo (duas colunas); no celular vira uma coluna só. %>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)]
|
||||
xl:grid-cols-[minmax(0,260px)_minmax(0,1fr)_minmax(0,520px)] gap-5 items-start">
|
||||
<%# Duas zonas na tela: VEÍCULOS e a edição. A prévia saiu do fluxo e virou
|
||||
sobreposição sob demanda (botão "Ver prévia") — antes o <iframe> era montado
|
||||
em TODO carregamento, e cada montagem gerava um PDF no servidor só para
|
||||
ficar num canto que o operador nem sempre olha. Agora o PDF só é gerado
|
||||
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">
|
||||
<%= render 'veiculos' %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 lg:col-span-1">
|
||||
<div class="space-y-4">
|
||||
<%= render 'controles' %>
|
||||
<%= render 'tabela', linhas: @linhas %>
|
||||
<%= render 'removidas', linhas: @removidas %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Prévia — no celular vira um bloco recolhido: 6 colunas em tela pequena
|
||||
exigiriam zoom, e o que importa ali é editar, não conferir o layout. %>
|
||||
<div class="lg:col-span-2 xl:col-span-1 xl:sticky xl:top-4">
|
||||
<details class="xl:hidden bg-[#111] border border-white/5 rounded-2xl p-4 mb-4">
|
||||
<summary class="cursor-pointer text-white font-medium select-none min-h-[44px] flex items-center">
|
||||
<%= icone :visualizar %> Ver prévia do PDF
|
||||
</summary>
|
||||
<%= link_to 'Abrir o PDF deste veículo',
|
||||
pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo),
|
||||
class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %>
|
||||
</details>
|
||||
<%# ── Prévia em sobreposição ──────────────────────────────────────────────
|
||||
Fica FORA da grade, ocupando a tela inteira quando aberta. `hidden` no
|
||||
markup e o src só preenchido na abertura: um iframe com src carrega
|
||||
mesmo escondido, o que anularia a economia.
|
||||
|
||||
<div class="hidden xl:block bg-[#111] border border-white/5 rounded-2xl p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-white font-semibold text-sm">Prévia — <%= @veiculo %></h2>
|
||||
<button type="button" data-action="romaneio#atualizarPreview"
|
||||
class="text-xs text-gray-400 hover:text-white px-3 py-2 rounded-lg hover:bg-white/5">
|
||||
<%= icone :restaurar, tamanho: 'w-4 h-4' %> Atualizar prévia
|
||||
</button>
|
||||
<iframe> e não <embed>/<object>: o CSP está em enforcing com
|
||||
`object_src :none`, e sem `frame_src` ele cai em `default_src :self` —
|
||||
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),
|
||||
target: '_blank', rel: 'noopener',
|
||||
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"
|
||||
aria-label="Fechar prévia"
|
||||
class="min-w-[48px] min-h-[48px] rounded-xl bg-[#1a1a1a] border border-white/10
|
||||
text-white hover:bg-[#242424]">✕</button>
|
||||
</div>
|
||||
<iframe data-romaneio-target="preview"
|
||||
src="<%= pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo) %>"
|
||||
class="w-full h-[78vh] rounded-xl border border-white/10 bg-white"
|
||||
title="Pré-visualização do romaneio do veículo <%= @veiculo %>"></iframe>
|
||||
</div>
|
||||
<iframe data-romaneio-target="preview"
|
||||
class="flex-1 w-full rounded-xl border border-white/10 bg-white"
|
||||
title="Pré-visualização do romaneio"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -40,6 +40,30 @@ if ! bundle check >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
# ── 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
|
||||
# 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
|
||||
|
||||
@@ -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}'))" \
|
||||
'.distinct.count(:vehicle)'
|
||||
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.'
|
||||
|
||||
@@ -8,8 +8,24 @@ Rails.application.configure do
|
||||
# HTTPS obrigatório: redireciona HTTP→HTTPS e marca cookies como "Secure".
|
||||
# assume_ssl faz o Rails confiar no TLS terminado no Cloudflare (que envia
|
||||
# X-Forwarded-Proto=https), evitando loop de redirecionamento.
|
||||
config.assume_ssl = true
|
||||
config.force_ssl = true
|
||||
#
|
||||
# ⚠️ 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.force_ssl = true
|
||||
end
|
||||
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
|
||||
config.log_tags = [:request_id]
|
||||
config.logger = ActiveSupport::Logger.new(STDOUT).tap { |l| l.formatter = ::Logger::Formatter.new }
|
||||
|
||||
78
spec/services/romaneios/casador_de_operacao_spec.rb
Normal file
78
spec/services/romaneios/casador_de_operacao_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user