diff --git a/.env.example b/.env.example index 0f1ec35..ef82558 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/controllers/admin/romaneios_controller.rb b/app/controllers/admin/romaneios_controller.rb index ad7d82b..8b82072 100644 --- a/app/controllers/admin/romaneios_controller.rb +++ b/app/controllers/admin/romaneios_controller.rb @@ -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:) diff --git a/app/javascript/controllers/romaneio_controller.js b/app/javascript/controllers/romaneio_controller.js index 27b30a9..e986b15 100644 --- a/app/javascript/controllers/romaneio_controller.js +++ b/app/javascript/controllers/romaneio_controller.js @@ -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 ─────────────────────────────────────────────────────────────── diff --git a/app/models/operacao.rb b/app/models/operacao.rb index ec96cce..3f9b928 100644 --- a/app/models/operacao.rb +++ b/app/models/operacao.rb @@ -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. diff --git a/app/models/romaneio.rb b/app/models/romaneio.rb index db46b81..6086524 100644 --- a/app/models/romaneio.rb +++ b/app/models/romaneio.rb @@ -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? diff --git a/app/services/romaneios/casador_de_operacao.rb b/app/services/romaneios/casador_de_operacao.rb new file mode 100644 index 0000000..4409c16 --- /dev/null +++ b/app/services/romaneios/casador_de_operacao.rb @@ -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 diff --git a/app/views/admin/romaneios/_veiculos.html.erb b/app/views/admin/romaneios/_veiculos.html.erb index 1af99cb..50bee13 100644 --- a/app/views/admin/romaneios/_veiculos.html.erb +++ b/app/views/admin/romaneios/_veiculos.html.erb @@ -63,11 +63,17 @@