Correção de alguns valores no dash principal
This commit is contained in:
148
app/services/analytics/notas_fora_operacao.rb
Normal file
148
app/services/analytics/notas_fora_operacao.rb
Normal 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
|
||||
@@ -2,12 +2,20 @@
|
||||
#
|
||||
# Núcleo de dados do "Dashboard de Operações".
|
||||
#
|
||||
# Espelha a query de gestão do cliente: pega o ÚLTIMO status de cada NF em
|
||||
# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz
|
||||
# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por
|
||||
# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os
|
||||
# painéis (KPIs, insucessos %, índices de falha, status, motoristas, STS, por dia
|
||||
# e o mapa de calor).
|
||||
# Espelha a query de gestão do cliente: casa db_reem_simplerout_2026 com a(s)
|
||||
# tabela(s) de operação (gade_entregas_*) por reference_id::text = nota_fiscal e
|
||||
# agrega em Ruby para alimentar os painéis (KPIs, insucessos %, índices de falha,
|
||||
# status, motoristas, STS, por dia e o mapa de calor).
|
||||
#
|
||||
# DUAS CAMADAS, de propósito:
|
||||
# #visitas — uma linha por ida do motorista ao local. É o que o dashboard
|
||||
# financeiro conta (Entrega.contar_atendidas) e o que se paga ao
|
||||
# motorista.
|
||||
# #linhas — uma linha por NOTA FISCAL (a última visita de cada uma). É o que
|
||||
# o cliente paga, o que confere com os documentos físicos e com a
|
||||
# aba ENTREGAS da planilha. TODOS os KPIs desta tela saem daqui.
|
||||
# Os dois números só coincidem quando nenhuma NF precisou de segunda ida; a
|
||||
# diferença aparece na tela como "retentativas" em vez de ficar escondida.
|
||||
#
|
||||
# SEGURANÇA: os nomes das tabelas de operação só entram no SQL depois de passar
|
||||
# pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão
|
||||
@@ -36,15 +44,30 @@ module Analytics
|
||||
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
||||
end
|
||||
|
||||
# Linhas após o cross-filter — base de TODAS as agregações/KPIs.
|
||||
def linhas
|
||||
@linhas ||= begin
|
||||
# VISITAS após o cross-filter: uma linha por passagem do motorista. Uma mesma
|
||||
# NF pode ter várias (falhou dia 10, entregou dia 12). É a base da análise
|
||||
# operacional — quantas idas ao local foram necessárias.
|
||||
def visitas
|
||||
@visitas ||= begin
|
||||
base = @filtros.empty? ? registros
|
||||
: registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
|
||||
@data ? base.select { |r| data_de(r['checkout']) == @data } : base
|
||||
end
|
||||
end
|
||||
|
||||
# NOTAS FISCAIS: uma linha por NF, com a ÚLTIMA visita dela. É a base de
|
||||
# TODOS os KPIs desta tela, porque é o que confere com os documentos físicos
|
||||
# e com a aba ENTREGAS da planilha entregue ao cliente (Analytics::
|
||||
# PlanilhaEntregas também usa "último status por reference_id").
|
||||
#
|
||||
# ⚠️ O dedup precisa acontecer AQUI — depois do período e do cross-filter — e
|
||||
# não no SQL. Enquanto ele rodava como ROW_NUMBER + `rn = 1` sobre a tabela
|
||||
# INTEIRA, uma NF reentregue DEPOIS do fim do período perdia a visita que
|
||||
# estava DENTRO dele e sumia da contagem do mês.
|
||||
def linhas
|
||||
@linhas ||= por_nota.values.map { |vs| vs.max_by { |r| ordem_visita(r) } }
|
||||
end
|
||||
|
||||
# Campos pesquisáveis da tabela espelho (planilha da operação).
|
||||
BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name
|
||||
address nome_completo endereco_completo status_gade operacao].freeze
|
||||
@@ -103,6 +126,34 @@ module Analytics
|
||||
total - sucesso - recusas
|
||||
end
|
||||
|
||||
# ── Camada operacional (por VISITA, não por NF) ───────────────
|
||||
# Os KPIs acima contam NOTAS porque é o que o cliente paga e confere. Estes
|
||||
# contam IDAS AO LOCAL — é o que o motorista recebe e o que o dashboard
|
||||
# financeiro usa (Entrega.contar_atendidas conta linhas). Sem eles, uma NF
|
||||
# que falhou duas vezes antes de ser entregue aparecia como 100% de sucesso
|
||||
# e o insucesso sumia da tela.
|
||||
def total_visitas
|
||||
visitas.size
|
||||
end
|
||||
|
||||
# Idas ao local além da primeira de cada NF.
|
||||
def retentativas
|
||||
total_visitas - total
|
||||
end
|
||||
|
||||
def visitas_insucesso
|
||||
visitas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||
end
|
||||
|
||||
# NFs que hoje constam como ENTREGUES mas custaram mais de uma ida — a
|
||||
# informação que o dedup escondia por completo.
|
||||
def notas_reentregues
|
||||
@notas_reentregues ||= por_nota.count do |_nf, vs|
|
||||
vs.any? { |r| Entrega::STATUS_FALHA.include?(r['status']) } &&
|
||||
vs.max_by { |r| ordem_visita(r) }['status'] == 'completed'
|
||||
end
|
||||
end
|
||||
|
||||
# Donut "Insucessos %": completas vs falhas (sobre o total).
|
||||
def insucessos_pct
|
||||
{
|
||||
@@ -206,6 +257,27 @@ module Analytics
|
||||
|
||||
private
|
||||
|
||||
# NF => visitas dela (já filtradas). Chave string: reference_id é numérico no
|
||||
# espelho e texto na tabela da operação.
|
||||
def por_nota
|
||||
@por_nota ||= visitas.group_by { |r| r['reference_id'].to_s }
|
||||
end
|
||||
|
||||
# Ordem de "última visita": quem tem checkout ganha de quem não tem e, entre
|
||||
# as com checkout, vence a mais recente. Mesma regra do ROW_NUMBER que a
|
||||
# planilha do cliente usa (checkout DESC NULLS LAST).
|
||||
def ordem_visita(registro)
|
||||
t = tempo_de(registro['checkout'])
|
||||
[t ? 1 : 0, t || Time.at(0)]
|
||||
end
|
||||
|
||||
def tempo_de(valor)
|
||||
return nil if valor.nil? || valor.to_s.strip.empty?
|
||||
valor.to_time
|
||||
rescue ArgumentError, TypeError, NoMethodError
|
||||
nil
|
||||
end
|
||||
|
||||
def datas_checkout
|
||||
@datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
|
||||
end
|
||||
@@ -247,35 +319,33 @@ module Analytics
|
||||
|
||||
conn = ActiveRecord::Base.connection
|
||||
|
||||
rastreio = conn.quote_table_name(Entrega.table_name)
|
||||
conta = Entrega.condicao_conta_sql('r')
|
||||
|
||||
unions = @tabelas.map do |tabela|
|
||||
gade = conn.quote_table_name(tabela)
|
||||
label = conn.quote(Operacao.label(tabela))
|
||||
<<~SQL.strip
|
||||
SELECT #{label} AS operacao,
|
||||
r.tracking_id,
|
||||
r.reference_id, r.driver, r.vehicle, r.status, r.observation, r.contact_name, r.address,
|
||||
r.checkout, r.planned_date, r.foto_da_fachada,
|
||||
r.latitude, r.longitude,
|
||||
r.checkout_latitude, r.checkout_longitude,
|
||||
#{selects_gade(conn, tabela)}
|
||||
FROM ultimo r
|
||||
FROM #{rastreio} r
|
||||
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
||||
WHERE r.rn = 1#{filtro_periodo(conn)}
|
||||
WHERE r.reference_id IS NOT NULL#{conta ? " AND #{conta}" : ''}#{filtro_periodo(conn)}
|
||||
SQL
|
||||
end
|
||||
|
||||
# Sem filtro de conta: o INNER JOIN com a tabela da operação (gade_entregas_*)
|
||||
# já restringe aos dados do cliente. Mantém a query idêntica à de gestão.
|
||||
sql = <<~SQL
|
||||
WITH ultimo AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
|
||||
FROM #{conn.quote_table_name(Entrega.table_name)}
|
||||
WHERE reference_id IS NOT NULL
|
||||
)
|
||||
#{unions.join("\nUNION ALL\n")}
|
||||
SQL
|
||||
|
||||
conn.select_all(sql).to_a
|
||||
# Traz TODAS as visitas; a redução para uma linha por NF é feita em #linhas,
|
||||
# já depois do período e do cross-filter (ver o comentário lá).
|
||||
#
|
||||
# `uniq` por tracking_id: se a mesma nota_fiscal estiver repetida dentro de
|
||||
# uma tabela de operação (ou em duas tabelas do UNION), o INNER JOIN
|
||||
# devolveria a MESMA visita mais de uma vez e inflaria a contagem.
|
||||
conn.select_all(unions.join("\nUNION ALL\n")).to_a.uniq { |r| r['tracking_id'] }
|
||||
end
|
||||
|
||||
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
|
||||
|
||||
Reference in New Issue
Block a user