From e2b74068d54b8c47a06e0020e73713d79f1e6dbcda21a2067e7b9adcab6e5b13 Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 27 Aug 2026 19:51:38 -0300 Subject: [PATCH] =?UTF-8?q?Implanta=C3=A7=C3=A3o=20da=20cria=C3=A7=C3=A3o?= =?UTF-8?q?=20de=20romaneios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 +- app/controllers/admin/romaneios_controller.rb | 30 +++- .../controllers/romaneio_controller.js | 46 ++++++- app/models/operacao.rb | 8 ++ app/models/romaneio.rb | 17 +++ app/services/romaneios/casador_de_operacao.rb | 130 ++++++++++++++++++ app/views/admin/romaneios/_veiculos.html.erb | 8 +- app/views/admin/romaneios/index.html.erb | 11 +- app/views/admin/romaneios/show.html.erb | 86 ++++++++---- bin/docker-boot | 24 ++++ bin/sondar_plano_do_dia | 69 ++++++++++ config/environments/production.rb | 20 ++- .../romaneios/casador_de_operacao_spec.rb | 78 +++++++++++ 13 files changed, 497 insertions(+), 39 deletions(-) create mode 100644 app/services/romaneios/casador_de_operacao.rb create mode 100644 spec/services/romaneios/casador_de_operacao_spec.rb 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 @@
+ <%# Prévia sob demanda: o PDF só é gerado quando alguém pede para ver. %> + <%= 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] diff --git a/app/views/admin/romaneios/index.html.erb b/app/views/admin/romaneios/index.html.erb index 1ef9c8a..d9042dc 100644 --- a/app/views/admin/romaneios/index.html.erb +++ b/app/views/admin/romaneios/index.html.erb @@ -73,7 +73,10 @@ +

+ Cole o nome do plano como está no SimpliRoute — é por ele que a + operação do mês (quem é NOVO) é encontrada. +

diff --git a/app/views/admin/romaneios/show.html.erb b/app/views/admin/romaneios/show.html.erb index d10aa6b..996ba70 100644 --- a/app/views/admin/romaneios/show.html.erb +++ b/app/views/admin/romaneios/show.html.erb @@ -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 + <% end %> diff --git a/bin/docker-boot b/bin/docker-boot index ebd8348..4a723d0 100755 --- a/bin/docker-boot +++ b/bin/docker-boot @@ -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 diff --git a/bin/sondar_plano_do_dia b/bin/sondar_plano_do_dia index 877589e..4433b2a 100755 --- a/bin/sondar_plano_do_dia +++ b/bin/sondar_plano_do_dia @@ -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//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.' diff --git a/config/environments/production.rb b/config/environments/production.rb index 7eeee1a..396427b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -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 } diff --git a/spec/services/romaneios/casador_de_operacao_spec.rb b/spec/services/romaneios/casador_de_operacao_spec.rb new file mode 100644 index 0000000..78deb3f --- /dev/null +++ b/spec/services/romaneios/casador_de_operacao_spec.rb @@ -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