131 lines
5.6 KiB
Ruby
131 lines
5.6 KiB
Ruby
# 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
|