190 lines
6.3 KiB
Ruby
190 lines
6.3 KiB
Ruby
# app/services/analytics/notas_fora_operacao.rb
|
|
#
|
|
# NFs que o motorista entregou no período mas que NÃO estão em nenhuma planilha
|
|
# de operação (`gade_entregas_*`) — as notas que entram por plano avulso ou de
|
|
# inclusão, fora do carregamento original do cliente.
|
|
#
|
|
# Elas contam no dashboard financeiro (o motorista foi ao local e recebe por
|
|
# isso) e SUMIAM do dashboard de operações, porque lá o INNER JOIN com a tabela
|
|
# da operação simplesmente as descarta. Era metade da divergência entre as duas
|
|
# telas — agora aparece como painel próprio em vez de virar diferença silenciosa.
|
|
#
|
|
# SEGURANÇA: os nomes das tabelas passam pela whitelist (Operacao.nomes_validos,
|
|
# que lê o catálogo) + quote_table_name. Bases SOMENTE LEITURA.
|
|
module Analytics
|
|
class NotasForaOperacao
|
|
# Teto da listagem na tela (os totais continuam contando tudo).
|
|
LIMITE = 300
|
|
|
|
# Colunas extras do espelho que a tela usa. Nem toda base tem todas — as
|
|
# ausentes viram NULL, mesmo padrão de selects_gade em OperacaoMetricas.
|
|
# Whitelist fixa: nada aqui vem do usuário.
|
|
COLUNAS_EXTRA = %w[title notes comments route_id].freeze
|
|
|
|
# Candidatas ao nome do PLANO de origem ("(Avulsa)", "INCLUSÃO"...), na ordem
|
|
# de preferência.
|
|
#
|
|
# ⚠️ `title` NÃO entra aqui: conferido em 24/08/2026 com dado real, ele traz
|
|
# "NF 89096 - KAIQUE TAUAN DA SILVA" — o formato da coluna A da planilha de
|
|
# importação (`NF {nota_fiscal} - {nome_completo}`), ou seja, o destinatário.
|
|
# Agrupar por ele daria um grupo por NF, o que não informa nada.
|
|
COLUNAS_PLANO = %w[notes comments route_id].freeze
|
|
|
|
def initialize(inicio:, fim:)
|
|
@inicio = inicio&.to_date
|
|
@fim = fim&.to_date
|
|
end
|
|
|
|
# Visitas cruas (pode haver mais de uma por NF).
|
|
def visitas
|
|
@visitas ||= carregar
|
|
end
|
|
|
|
# Uma linha por NF: a última visita dela. Mesmo critério de OperacaoMetricas.
|
|
def linhas
|
|
@linhas ||= visitas.group_by { |r| r['reference_id'].to_s }
|
|
.values
|
|
.map { |vs| vs.max_by { |r| ordem_visita(r) } }
|
|
.sort_by { |r| r['checkout'].to_s }
|
|
.reverse
|
|
end
|
|
|
|
def total
|
|
linhas.size
|
|
end
|
|
|
|
def entregues
|
|
linhas.count { |r| r['status'] == 'completed' }
|
|
end
|
|
|
|
def nao_entregues
|
|
linhas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
|
end
|
|
|
|
def pendentes
|
|
total - entregues - nao_entregues
|
|
end
|
|
|
|
def any?
|
|
total.positive?
|
|
end
|
|
|
|
# Nome do plano de origem: primeira coluna de COLUNAS_PLANO preenchida.
|
|
# nil quando o espelho não guarda essa informação.
|
|
def plano(registro)
|
|
COLUNAS_PLANO.each do |coluna|
|
|
valor = registro[coluna].to_s.strip
|
|
return valor if valor.present?
|
|
end
|
|
nil
|
|
end
|
|
|
|
# Destinatário/título da visita (coluna `title` do espelho).
|
|
def titulo(registro)
|
|
registro['title'].to_s.strip.presence
|
|
end
|
|
|
|
def plano_identificado?
|
|
linhas.any? { |r| plano(r) }
|
|
end
|
|
|
|
# Quebra do painel em grupos. Quando o espelho traz o plano, separa
|
|
# "(Avulsa)" de "INCLUSÃO"; quando não traz, agrupar por título daria um
|
|
# grupo por NF — então cai para a unidade, que ainda diz algo útil.
|
|
# Devolve [rótulo do agrupamento, [{ nome:, total: }, ...]].
|
|
def agrupamento
|
|
if plano_identificado?
|
|
['Plano', agrupar { |r| plano(r) || 'SEM PLANO' }]
|
|
else
|
|
['Unidade', agrupar { |r| r['contact_name'].to_s.strip.presence || 'SEM UNIDADE' }]
|
|
end
|
|
end
|
|
|
|
# Rótulo legível do resultado da última visita.
|
|
RESULTADOS = { 'completed' => 'Entregue', 'failed' => 'Não entregue',
|
|
'pending' => 'Em aberto', 'in_progress' => 'Em rota' }.freeze
|
|
|
|
def resultado(registro)
|
|
status = registro['status'].to_s
|
|
RESULTADOS[status] || status.presence || 'sem status'
|
|
end
|
|
|
|
def sucesso?(registro)
|
|
registro['status'] == 'completed'
|
|
end
|
|
|
|
def listagem
|
|
linhas.first(LIMITE)
|
|
end
|
|
|
|
def truncada?
|
|
total > LIMITE
|
|
end
|
|
|
|
private
|
|
|
|
def agrupar
|
|
linhas.group_by { |r| yield(r) }
|
|
.map { |nome, rows| { nome: nome, total: rows.size } }
|
|
.sort_by { |h| -h[:total] }
|
|
end
|
|
|
|
def ordem_visita(registro)
|
|
t = registro['checkout'].presence&.to_time
|
|
[t ? 1 : 0, t || Time.at(0)]
|
|
rescue ArgumentError, TypeError
|
|
[0, Time.at(0)]
|
|
end
|
|
|
|
def carregar
|
|
tabelas = Operacao.nomes_validos
|
|
return [] if tabelas.empty?
|
|
|
|
conn = ActiveRecord::Base.connection
|
|
rastreio = conn.quote_table_name(Entrega.table_name)
|
|
conta = Entrega.condicao_conta_sql('r')
|
|
|
|
# `nota_fiscal IS NOT NULL` é OBRIGATÓRIO: um único NULL na subquery faz o
|
|
# NOT IN devolver ZERO linhas (semântica de três valores do SQL) e o painel
|
|
# apareceria vazio para sempre.
|
|
conhecidas = tabelas.map do |t|
|
|
"SELECT nota_fiscal FROM #{conn.quote_table_name(t)} WHERE nota_fiscal IS NOT NULL"
|
|
end.join(' UNION ')
|
|
|
|
sql = <<~SQL
|
|
SELECT r.tracking_id, r.reference_id, r.driver, r.vehicle, r.status, r.observation,
|
|
r.contact_name, r.address, r.checkout, r.planned_date,
|
|
#{selects_plano(conn)}
|
|
FROM #{rastreio} r
|
|
WHERE r.reference_id IS NOT NULL
|
|
#{conta ? "AND #{conta}" : ''}
|
|
#{filtro_periodo(conn)}
|
|
AND r.reference_id::text NOT IN (#{conhecidas})
|
|
SQL
|
|
|
|
conn.select_all(sql).to_a
|
|
end
|
|
|
|
# Colunas extras que existirem de fato; as demais viram NULL com o mesmo
|
|
# alias, para a leitura da linha não precisar saber quais existem.
|
|
def selects_plano(conn)
|
|
existentes = conn.columns(Entrega.table_name).map(&:name)
|
|
COLUNAS_EXTRA.map do |coluna|
|
|
existentes.include?(coluna) ? "r.#{coluna}" : "CAST(NULL AS text) AS #{coluna}"
|
|
end.join(', ')
|
|
end
|
|
|
|
# Mesmo recorte de OperacaoMetricas#filtro_periodo: atendidas pela data real
|
|
# (checkout); em aberto (sem checkout) pela data planejada.
|
|
def filtro_periodo(conn)
|
|
return '' unless @inicio && @fim
|
|
|
|
ini = conn.quote(@inicio)
|
|
fim_excl = conn.quote(@fim + 1)
|
|
fim_dia = conn.quote(@fim.end_of_day)
|
|
"AND ((r.checkout >= #{ini} AND r.checkout < #{fim_excl})" \
|
|
" OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia}))"
|
|
end
|
|
end
|
|
end
|