Correção de alguns valores no dash principal

This commit is contained in:
2026-08-24 12:07:56 -03:00
parent 7b3ec3e407
commit 04b78bc7de
12 changed files with 690 additions and 50 deletions

View File

@@ -0,0 +1,148 @@
# 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 do espelho que podem carregar o nome do PLANO/rota de origem
# ("(Avulsa)", "INCLUSÃO"...). 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_PLANO = %w[title 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
# Agrupamento por plano de origem, quando a base tiver alguma das colunas de
# COLUNAS_PLANO preenchida. Serve para separar "(Avulsa)" de "INCLUSÃO".
def por_plano
linhas.group_by { |r| plano(r) }
.map { |nome, rows| { nome: nome, total: rows.size } }
.sort_by { |h| -h[:total] }
end
# Rótulo do plano de uma linha: primeira coluna de COLUNAS_PLANO preenchida.
def plano(registro)
COLUNAS_PLANO.each do |coluna|
valor = registro[coluna].to_s.strip
return valor if valor.present?
end
'SEM PLANO IDENTIFICADO'
end
def listagem
linhas.first(LIMITE)
end
def truncada?
total > LIMITE
end
private
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 de plano 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_PLANO.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