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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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