# 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