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

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