Correção de itens que estão fora do plano valores no dash principal

This commit is contained in:
2026-08-24 16:26:27 -03:00
parent 04b78bc7de
commit 94bbe7556f
10 changed files with 403 additions and 48 deletions

View File

@@ -50,6 +50,40 @@ class DashboardController < ApplicationController
disposition: 'attachment'
end
# GET /dashboard/pendentes?inicio=&fim=&operacoes[]=
# Quais são as entregas EM ABERTO do período — o card do dashboard mostrava só
# o número e não havia tela nenhuma listando as linhas por trás dele.
#
# Em aberto = nem concluída nem falhada (Entrega.pendentes), pela data
# PLANEJADA: é o único recorte sem checkout, então planned_date é o eixo
# possível. Mesmo recorte do card, para o total bater.
def pendentes
skip_authorization
return redirect_to(motorista_dashboard_path) if current_user.motorista?
@periodo_inicio, @periodo_fim = periodo_selecionado
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
escopo = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim)
escopo = escopo.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
@total_em_aberto = escopo.count
begin
@pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50)
rescue Pagy::OverflowError
@pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50, page: 1)
end
# Contexto de cada NF listada: em que operação ela está (se estiver) e
# quantas visitas o rastreio tem para ela. As duas respostas juntas dizem por
# que a entrega ficou em aberto — e por que o dashboard de Operações não a
# mostra: ou é nota fora da operação, ou tem outra visita que a substituiu.
nfs = @em_aberto.map { |e| e.reference_id.to_s }.reject(&:empty?)
@operacao_por_nf = Operacao.por_notas(nfs)
@visitas_por_nf = nfs.any? ? Entrega.where(reference_id: nfs).group(:reference_id).count : {}
end
private
# Faixa de datas vinda do filtro de calendário (params inicio/fim).

View File

@@ -78,6 +78,31 @@ class Operacao
grupos.sort_by { |titulo, _| ordem[titulo] }.map { |titulo, itens| [titulo, itens] }
end
# Em QUAIS operações cada NF aparece: { '85382' => ['UBS NORTE AGO 2026'] }.
# NFs que não estiverem em nenhuma planilha simplesmente não vêm no Hash — é
# assim que a tela de pendentes marca "fora da operação" (plano avulso/de
# inclusão) sem precisar de uma query por tabela.
#
# SEGURANÇA: as tabelas vêm de #nomes_validos (catálogo) + quote_table_name;
# as NFs entram por conn.quote. Uma query só, com UNION ALL.
def por_notas(notas)
lista = Array(notas).map(&:to_s).map(&:strip).reject(&:empty?).uniq
return {} if lista.empty?
tabelas = nomes_validos
return {} if tabelas.empty?
valores = lista.map { |n| conn.quote(n) }.join(', ')
sql = tabelas.map do |t|
"SELECT nota_fiscal, #{conn.quote(label(t))} AS operacao " \
"FROM #{conn.quote_table_name(t)} WHERE nota_fiscal IN (#{valores})"
end.join(' UNION ALL ')
conn.select_all(sql).to_a
.group_by { |r| r['nota_fiscal'].to_s }
.transform_values { |rows| rows.map { |r| r['operacao'] }.uniq.sort }
end
# Dados agregados das entregas das operações informadas, para pré-preencher o
# formulário de nova consolidação. Une as NFs de todas as tabelas válidas.
def dados(tabelas)

View File

@@ -16,11 +16,19 @@ module Analytics
# 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
# 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
@@ -61,21 +69,48 @@ module Analytics
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.
# 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
'SEM PLANO IDENTIFICADO'
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
@@ -88,6 +123,12 @@ module Analytics
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)]
@@ -124,11 +165,11 @@ module Analytics
conn.select_all(sql).to_a
end
# Colunas de plano que existirem de fato; as demais viram NULL com o mesmo
# 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_PLANO.map do |coluna|
COLUNAS_EXTRA.map do |coluna|
existentes.include?(coluna) ? "r.#{coluna}" : "CAST(NULL AS text) AS #{coluna}"
end.join(', ')
end

View File

@@ -164,7 +164,15 @@
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span class="text-green-400"><%= @entregas_pagas %> entregues</span>
<span class="text-gray-400">·</span>
<span class="text-yellow-500"><%= @entregas_pendentes %> pendentes</span>
<%# Clicável: leva à lista das entregas em aberto, no MESMO recorte do card
(período + filtro de operação). O número existia sem nenhuma tela por trás. %>
<%= link_to "#{@entregas_pendentes} pendentes",
dashboard_pendentes_path(inicio: @periodo_inicio.strftime('%Y-%m-%d'),
fim: @periodo_fim.strftime('%Y-%m-%d'),
operacoes: @operacao_filtro.presence),
data: { turbo: false },
title: 'Ver quais são as entregas em aberto',
class: 'text-yellow-500 underline decoration-dotted underline-offset-4 hover:text-yellow-300' %>
<span class="text-gray-400">·</span>
<span class="text-red-400"><%= @entregas_falhadas %> falhadas</span>
</div>

View File

@@ -0,0 +1,113 @@
<%# app/views/dashboard/pendentes.html.erb
O que há por trás do card "N pendentes": as entregas EM ABERTO do período —
nem concluídas nem falhadas, pela data planejada. Antes o número existia e
não havia tela nenhuma listando as linhas. %>
<% ctx = { inicio: @periodo_inicio.strftime('%Y-%m-%d'), fim: @periodo_fim.strftime('%Y-%m-%d'), operacoes: @operacao_filtro.presence }.compact %>
<div class="space-y-6">
<%# ── Cabeçalho ─────────────────────────────────────────── %>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-white">Entregas em aberto</h1>
<p class="text-gray-400 text-sm mt-0.5">
Planejadas entre <%= @periodo_inicio.strftime('%d/%m/%Y') %> e <%= @periodo_fim.strftime('%d/%m/%Y') %>
sem fechamento — o motorista não registrou nem sucesso nem insucesso.
<% if @operacao_filtro.present? %>
· <%= @operacao_filtro.map { |t| Operacao.label(t) }.join(', ') %>
<% end %>
</p>
</div>
<%= link_to ' Voltar ao dashboard', dashboard_path(ctx), data: { turbo: false },
class: 'px-4 py-2.5 rounded-xl text-sm whitespace-nowrap bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-3 px-6 py-4 border-b border-white/5">
<p class="text-white font-bold">
<%= @total_em_aberto %> <%= 'entrega'.pluralize(@total_em_aberto) %> em aberto
</p>
<p class="text-xs text-gray-500">
A coluna <strong class="text-gray-400">Operação</strong> diz se a NF está em alguma planilha
<code class="text-gray-400">gade_entregas_*</code>; <strong class="text-gray-400">Visitas</strong> é quantas
vezes o rastreio tem essa NF. As duas juntas explicam por que ela ficou em aberto.
</p>
</div>
<% if @em_aberto.empty? %>
<p class="px-6 py-10 text-center text-gray-500 text-sm">
Nenhuma entrega em aberto neste período. 🎉
</p>
<% else %>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-white/5 text-gray-400 text-xs uppercase tracking-wider">
<tr>
<th class="text-left px-6 py-3 font-medium">NF</th>
<th class="text-left px-4 py-3 font-medium">Status</th>
<th class="text-left px-4 py-3 font-medium">Motorista</th>
<th class="text-left px-4 py-3 font-medium">Veículo</th>
<th class="text-left px-4 py-3 font-medium">Unidade</th>
<th class="text-left px-4 py-3 font-medium">Planejada</th>
<th class="text-left px-4 py-3 font-medium">Operação</th>
<th class="text-right px-4 py-3 font-medium">Visitas</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<% @em_aberto.each do |entrega| %>
<% nf = entrega.reference_id.to_s %>
<% operacao = @operacao_por_nf[nf] %>
<% visitas = @visitas_por_nf[entrega.reference_id].to_i %>
<tr class="hover:bg-white/5">
<td class="px-6 py-2.5 text-white font-mono"><%= entrega.reference_id %></td>
<td class="px-4 py-2.5">
<span class="px-2 py-0.5 rounded-full bg-yellow-500/15 border border-yellow-500/30 text-yellow-300 text-xs">
<%= entrega.status.presence || 'sem status' %>
</span>
</td>
<td class="px-4 py-2.5 text-gray-300"><%= entrega.driver.presence || '— sem motorista' %></td>
<td class="px-4 py-2.5 text-gray-300"><%= entrega.vehicle.presence || '—' %></td>
<td class="px-4 py-2.5 text-gray-300"><%= entrega.contact_name.presence || '—' %></td>
<td class="px-4 py-2.5 text-gray-400 whitespace-nowrap"><%= entrega.planned_date&.strftime('%d/%m/%Y') || '—' %></td>
<td class="px-4 py-2.5">
<% if operacao.present? %>
<span class="text-gray-300"><%= operacao.join(', ') %></span>
<% else %>
<span class="text-amber-400">fora da operação</span>
<% end %>
</td>
<td class="px-4 py-2.5 text-right">
<span class="<%= visitas > 1 ? 'text-amber-400 font-bold' : 'text-gray-400' %>"><%= visitas %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%# Navegação de páginas (mesmo padrão de consolidacao_entregas/revisar) %>
<% if @pagy.pages > 1 %>
<% pag = ->(p) { dashboard_pendentes_path(ctx.merge(page: p)) } %>
<nav class="flex items-center justify-center gap-1.5 py-4 flex-wrap border-t border-white/5">
<% if @pagy.prev %>
<%= link_to '', pag.call(@pagy.prev), data: { turbo: false },
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
<% end %>
<% @pagy.series.each do |item| %>
<% if item == :gap %>
<span class="px-2 text-gray-500">…</span>
<% elsif item.is_a?(String) %>
<span class="min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg bg-orange-500 text-black font-bold"><%= item %></span>
<% else %>
<%= link_to item, pag.call(item), data: { turbo: false },
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
<% end %>
<% end %>
<% if @pagy.next %>
<%= link_to '', pag.call(@pagy.next), data: { turbo: false },
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
<% end %>
</nav>
<% end %>
<% end %>
</div>
</div>

View File

@@ -29,9 +29,12 @@
Nenhuma nota fora das planilhas de operação neste período.
</p>
<% else %>
<%# Quebra por plano de origem — é aqui que "(Avulsa)" e "INCLUSÃO" se separam. %>
<div class="flex flex-wrap gap-2 px-6 py-3 border-b border-white/5">
<% fora.por_plano.first(8).each do |grupo| %>
<%# Quebra por plano de origem — é aqui que "(Avulsa)" e "INCLUSÃO" se separam.
Se o espelho não guardar o plano, cai para unidade (ver #agrupamento). %>
<% rotulo_grupo, grupos = fora.agrupamento %>
<div class="flex flex-wrap items-center gap-2 px-6 py-3 border-b border-white/5">
<span class="text-xs text-gray-500 uppercase tracking-wider"><%= rotulo_grupo %>:</span>
<% grupos.first(8).each do |grupo| %>
<span class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/5 border border-white/10 text-xs text-gray-300">
<%= grupo[:nome] %> <strong class="text-white"><%= grupo[:total] %></strong>
</span>
@@ -43,7 +46,8 @@
<thead class="bg-white/5 text-gray-400 text-xs uppercase tracking-wider">
<tr>
<th class="text-left px-6 py-3 font-medium">NF</th>
<th class="text-left px-4 py-3 font-medium">Plano / título</th>
<th class="text-left px-4 py-3 font-medium">Destinatário</th>
<th class="text-left px-4 py-3 font-medium">Plano</th>
<th class="text-left px-4 py-3 font-medium">Motorista</th>
<th class="text-left px-4 py-3 font-medium">Unidade</th>
<th class="text-left px-4 py-3 font-medium">Data</th>
@@ -53,17 +57,17 @@
</thead>
<tbody class="divide-y divide-white/5">
<% fora.listagem.each do |linha| %>
<% sucesso = linha['status'] == 'completed' %>
<tr class="hover:bg-white/5">
<td class="px-6 py-2.5 text-white font-mono"><%= linha['reference_id'] %></td>
<td class="px-4 py-2.5 text-gray-300 max-w-[22rem] truncate" title="<%= fora.plano(linha) %>"><%= fora.plano(linha) %></td>
<td class="px-4 py-2.5 text-gray-300"><%= linha['driver'].presence || '—' %></td>
<td class="px-4 py-2.5 text-gray-300 max-w-[22rem] truncate" title="<%= fora.titulo(linha) %>"><%= fora.titulo(linha) || '—' %></td>
<td class="px-4 py-2.5 text-gray-400"><%= fora.plano(linha) || '—' %></td>
<td class="px-4 py-2.5 text-gray-300"><%= linha['driver'].presence || '— sem motorista' %></td>
<td class="px-4 py-2.5 text-gray-300"><%= linha['contact_name'].presence || '—' %></td>
<td class="px-4 py-2.5 text-gray-400 whitespace-nowrap">
<%= (linha['checkout'].presence || linha['planned_date'].presence)&.to_date&.strftime('%d/%m/%Y') || '—' %>
</td>
<td class="px-4 py-2.5">
<span class="<%= sucesso ? 'text-green-400' : 'text-blue-400' %>"><%= sucesso ? 'Entregue' : (linha['status'].presence || '—') %></span>
<span class="<%= fora.sucesso?(linha) ? 'text-green-400' : 'text-yellow-400' %>"><%= fora.resultado(linha) %></span>
</td>
<td class="px-4 py-2.5 text-gray-400"><%= linha['observation'].presence || '—' %></td>
</tr>