Correção de itens que estão fora do plano valores no dash principal
This commit is contained in:
39
README.md
39
README.md
@@ -2176,6 +2176,26 @@ inteira — senão abrir uma operação de meses atrás cairia no mês corrente
|
||||
|
||||
**Financeiro — linha "N notas fiscais"** no card Total Entregas, ao lado de "visitas atendidas".
|
||||
|
||||
**Financeiro — card "N pendentes" agora é clicável** → `/dashboard/pendentes`, a tela nova
|
||||
**"Entregas em aberto"**. O número existia desde sempre e **nenhuma tela listava as linhas por trás
|
||||
dele** — só dava para descobrir por `rails runner`. A lista traz NF, status, motorista, veículo,
|
||||
unidade, data planejada, **em qual operação a NF está** (ou "fora da operação") e **quantas visitas
|
||||
o rastreio tem para ela**. Essas duas últimas colunas respondem sozinhas por que a entrega ficou em
|
||||
aberto e por que o dashboard de Operações não a mostrava.
|
||||
|
||||
### 🔍 O que o dado real mostrou (24/08/2026, teste)
|
||||
A "1 pendente" que não aparecia em Operações era a **NF 85382 — MARIA APARECIDA JESUS SANTOS**:
|
||||
plano avulso, status `pending`, **sem motorista**, planejada 03/08/2026, STS VILA PRUDENTE _
|
||||
SAPOPEMBA. Nota **fora da operação** — o `INNER JOIN` a descartava. Confirmado no painel novo.
|
||||
|
||||
⚠️ **`title` NÃO é o nome do plano.** No 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 dava um grupo por NF. `COLUNAS_PLANO` passou a ser
|
||||
`notes, comments, route_id`; `title` virou a coluna "Destinatário". Quando nenhuma coluna de plano
|
||||
vem preenchida, a quebra cai para **unidade**, que ainda informa algo. **A coluna que carrega
|
||||
"(Avulsa)"/"INCLUSÃO" segue não confirmada** — pode ser que o espelho simplesmente não a traga
|
||||
(o sync já deixa 4 colunas 100% NULL).
|
||||
|
||||
### ⚙️ Pontos não-óbvios
|
||||
|
||||
**Duas camadas no mesmo objeto.** `OperacaoMetricas#visitas` = uma linha por ida; `#linhas` = uma
|
||||
@@ -2198,16 +2218,20 @@ Tem spec para isso.
|
||||
### 📂 Arquivos
|
||||
```
|
||||
app/models/entrega.rb (contas_gade + condicao_conta_sql)
|
||||
app/models/operacao.rb (por_notas — em que operação cada NF está)
|
||||
app/services/analytics/operacao_metricas.rb (visitas x linhas; dedup pós-filtro; conta)
|
||||
app/services/analytics/notas_fora_operacao.rb (NOVO — avulsas/inclusão)
|
||||
app/controllers/operacoes_dashboard_controller.rb (período no modo Operação; @fora_operacao)
|
||||
app/controllers/dashboard_controller.rb (@notas_atendidas)
|
||||
app/controllers/dashboard_controller.rb (@notas_atendidas + action #pendentes)
|
||||
app/views/dashboard/pendentes.html.erb (NOVO — tela "Entregas em aberto")
|
||||
config/routes.rb (GET /dashboard/pendentes)
|
||||
app/views/operacoes_dashboard/_painel.html.erb (faixa "Visitas ao local")
|
||||
app/views/operacoes_dashboard/_fora_operacao.html.erb (NOVO — painel de avulsas)
|
||||
app/views/operacoes_dashboard/index.html.erb (seletor de período + render do painel)
|
||||
app/views/dashboard/index.html.erb (linha "N notas fiscais")
|
||||
app/views/dashboard/index.html.erb (linha "N notas fiscais" + card clicável)
|
||||
spec/services/analytics/operacao_metricas_spec.rb (+ NF com retentativa)
|
||||
spec/services/analytics/notas_fora_operacao_spec.rb (NOVO)
|
||||
spec/requests/dashboard_spec.rb (+ tela de entregas em aberto)
|
||||
```
|
||||
|
||||
> **Sem migration e sem gem nova** — model, services, controllers, views e specs.
|
||||
@@ -2218,12 +2242,15 @@ spec/services/analytics/notas_fora_operacao_spec.rb (NOVO)
|
||||
docker compose exec app bundle exec rspec \
|
||||
spec/services/analytics/operacao_metricas_spec.rb \
|
||||
spec/services/analytics/notas_fora_operacao_spec.rb \
|
||||
spec/requests/dashboard_spec.rb \
|
||||
spec/models/entrega_spec.rb
|
||||
|
||||
# 2. Conferir a coluna que carrega o nome do PLANO ("(Avulsa)", "INCLUSÃO").
|
||||
# NotasForaOperacao::COLUNAS_PLANO tenta title, notes, comments, route_id nessa ordem.
|
||||
# Se o painel mostrar "SEM PLANO IDENTIFICADO", a coluna certa é outra:
|
||||
docker compose exec app bin/rails runner 'puts Entrega.column_names.sort'
|
||||
# 2. Achar a coluna do PLANO ("(Avulsa)", "INCLUSÃO"). title JÁ foi descartado
|
||||
# (é o destinatário). Dump de uma nota avulsa real para ver onde o plano está:
|
||||
docker compose exec app bin/rails runner '
|
||||
e = Entrega.por_nf(85382).first
|
||||
e&.attributes&.reject { |_, v| v.blank? }&.each { |k, v| puts "#{k.ljust(28)} #{v}" }
|
||||
'
|
||||
|
||||
# 3. Com dado real, no mesmo período nas duas telas:
|
||||
# financeiro "N notas fiscais" == Operações Global "Total de Entregas"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
113
app/views/dashboard/pendentes.html.erb
Normal file
113
app/views/dashboard/pendentes.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,8 @@ Rails.application.routes.draw do
|
||||
# Dashboard principal (admin/gerente/operador)
|
||||
get '/dashboard', to: 'dashboard#index', as: :dashboard
|
||||
get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro
|
||||
# Entregas em aberto do período (o que há por trás do card "N pendentes")
|
||||
get '/dashboard/pendentes', to: 'dashboard#pendentes', as: :dashboard_pendentes
|
||||
|
||||
# Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...)
|
||||
get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard
|
||||
|
||||
@@ -218,3 +218,66 @@ RSpec.describe 'Dashboard — aba Consolidado do ranking de motoristas', type: :
|
||||
expect(painel_consolidado).to include('Nenhuma consolidação finalizada neste período')
|
||||
end
|
||||
end
|
||||
|
||||
# Tela "Entregas em aberto" — o que há por trás do card "N pendentes".
|
||||
#
|
||||
# Em aberto = nem concluída nem falhada, pela data PLANEJADA (é o único recorte
|
||||
# sem checkout). Tem que usar o MESMO recorte do card, senão o número do
|
||||
# dashboard leva a uma lista que não bate com ele.
|
||||
RSpec.describe 'Dashboard — entregas em aberto', type: :request do
|
||||
include_context 'espelho de rastreio'
|
||||
|
||||
let(:inicio) { '2026-08-01' }
|
||||
let(:fim) { '2026-08-14' }
|
||||
|
||||
before { sign_in create(:gerente) }
|
||||
|
||||
def abrir_pendentes
|
||||
get dashboard_pendentes_path, params: { inicio: inicio, fim: fim }
|
||||
expect(response).to have_http_status(:ok)
|
||||
response.body
|
||||
end
|
||||
|
||||
it 'lista só o que está em aberto dentro do período' do
|
||||
semear([
|
||||
# Em aberto no período — a única que deve aparecer.
|
||||
{ reference_id: 85_382, driver: nil, status: 'pending',
|
||||
planned_date: Time.zone.local(2026, 8, 3, 8, 0), checkout: nil },
|
||||
# Fechadas: não são "em aberto", mesmo planejadas no período.
|
||||
{ reference_id: 85_383, driver: 'CARLOS', status: 'completed',
|
||||
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) },
|
||||
{ reference_id: 85_384, driver: 'CARLOS', status: 'failed',
|
||||
planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) },
|
||||
# Em aberto, mas planejada FORA do período.
|
||||
{ reference_id: 85_385, driver: nil, status: 'pending',
|
||||
planned_date: Time.zone.local(2026, 9, 2, 8, 0), checkout: nil }
|
||||
])
|
||||
|
||||
corpo = abrir_pendentes
|
||||
expect(corpo).to include('85382')
|
||||
expect(corpo).not_to include('85383')
|
||||
expect(corpo).not_to include('85384')
|
||||
expect(corpo).not_to include('85385')
|
||||
end
|
||||
|
||||
# Sem nenhuma tabela gade_entregas_* no banco de teste, toda NF é "fora da
|
||||
# operação" — que é exatamente o caso real da NF 85382 que motivou a tela.
|
||||
it 'marca a NF que não está em nenhuma planilha de operação' do
|
||||
semear([{ reference_id: 85_382, driver: nil, status: 'pending',
|
||||
planned_date: Time.zone.local(2026, 8, 3, 8, 0), checkout: nil }])
|
||||
|
||||
expect(abrir_pendentes).to include('fora da operação')
|
||||
end
|
||||
|
||||
it 'o card do dashboard aponta para esta tela com o mesmo período' do
|
||||
create(:configuracao, chave: 'preco_entrega', valor: '10.0')
|
||||
semear([{ reference_id: 85_382, driver: nil, status: 'pending',
|
||||
planned_date: Time.zone.local(2026, 8, 3, 8, 0), checkout: nil }])
|
||||
|
||||
get dashboard_path, params: { inicio: inicio, fim: fim }
|
||||
# O href sai HTML-escapado (&) e a ordem dos params varia — confere o
|
||||
# caminho e o período separadamente.
|
||||
expect(response.body).to include(dashboard_pendentes_path)
|
||||
expect(response.body).to match(%r{/dashboard/pendentes\?[^"]*inicio=2026-08-01})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,11 +6,11 @@ RSpec.describe Analytics::NotasForaOperacao do
|
||||
# Visitas já filtradas pelo SQL (NFs que não estão em nenhuma gade_entregas_*).
|
||||
let(:visitas) do
|
||||
[
|
||||
row(nf: 100, status: 'failed', driver: 'Carlos', checkout: '2026-08-10 09:00:00', title: 'NF 100 - FULANO (Avulsa)'),
|
||||
row(nf: 100, status: 'completed', driver: 'Carlos', checkout: '2026-08-12 09:00:00', title: 'NF 100 - FULANO (Avulsa)'),
|
||||
row(nf: 101, status: 'completed', driver: 'Pedro', checkout: '2026-08-11 09:00:00', title: 'INCLUSÃO'),
|
||||
row(nf: 102, status: 'failed', driver: 'Pedro', checkout: '2026-08-13 09:00:00', title: 'INCLUSÃO', obs: 'ÓBITO'),
|
||||
row(nf: 103, status: 'pending', driver: 'Marcos', planned: '2026-08-20', title: nil)
|
||||
row(nf: 100, status: 'failed', driver: 'Carlos', sts: 'STS PENHA', checkout: '2026-08-10 09:00:00'),
|
||||
row(nf: 100, status: 'completed', driver: 'Carlos', sts: 'STS PENHA', checkout: '2026-08-12 09:00:00'),
|
||||
row(nf: 101, status: 'completed', driver: 'Pedro', sts: 'STS PENHA', checkout: '2026-08-11 09:00:00'),
|
||||
row(nf: 102, status: 'failed', driver: 'Pedro', sts: 'STS SAPOPEMBA', checkout: '2026-08-13 09:00:00', obs: 'ÓBITO'),
|
||||
row(nf: 103, status: 'pending', driver: nil, sts: 'STS SAPOPEMBA', planned: '2026-08-20')
|
||||
]
|
||||
end
|
||||
|
||||
@@ -23,18 +23,55 @@ RSpec.describe Analytics::NotasForaOperacao do
|
||||
expect(fora.pendentes).to eq(1)
|
||||
end
|
||||
|
||||
it 'agrupa por plano de origem (Avulsa x INCLUSÃO)' do
|
||||
expect(fora.por_plano).to contain_exactly(
|
||||
{ nome: 'INCLUSÃO', total: 2 },
|
||||
{ nome: 'NF 100 - FULANO (Avulsa)', total: 1 },
|
||||
{ nome: 'SEM PLANO IDENTIFICADO', total: 1 }
|
||||
)
|
||||
end
|
||||
|
||||
it 'ordena a listagem da visita mais recente para a mais antiga' do
|
||||
expect(fora.listagem.map { |r| r['reference_id'] }).to eq([102, 100, 101, 103])
|
||||
end
|
||||
|
||||
it 'traduz o resultado da última visita' do
|
||||
linha = fora.linhas.find { |r| r['reference_id'] == 103 }
|
||||
expect(fora.resultado(linha)).to eq('Em aberto')
|
||||
expect(fora.sucesso?(linha)).to be(false)
|
||||
end
|
||||
|
||||
# Regressão do dado real (24/08/2026): `title` traz "NF 89096 - KAIQUE TAUAN DA
|
||||
# SILVA" — o destinatário, não o plano. Agrupar por ele daria um grupo por NF.
|
||||
context 'quando o espelho só tem `title` (destinatário)' do
|
||||
let(:visitas) do
|
||||
[
|
||||
row(nf: 100, status: 'completed', driver: 'Thiago', sts: 'STS PENHA', checkout: '2026-08-12 09:00:00', title: 'NF 100 - KAIQUE TAUAN DA SILVA'),
|
||||
row(nf: 101, status: 'pending', driver: nil, sts: 'STS PENHA', planned: '2026-08-03', title: 'NF 101 - MARIA APARECIDA')
|
||||
]
|
||||
end
|
||||
|
||||
it 'não trata o título como plano' do
|
||||
expect(fora.plano(fora.linhas.first)).to be_nil
|
||||
expect(fora.titulo(fora.linhas.first)).to start_with('NF ')
|
||||
expect(fora.plano_identificado?).to be(false)
|
||||
end
|
||||
|
||||
it 'cai para o agrupamento por unidade' do
|
||||
rotulo, grupos = fora.agrupamento
|
||||
expect(rotulo).to eq('Unidade')
|
||||
expect(grupos).to eq([{ nome: 'STS PENHA', total: 2 }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'quando o espelho traz o plano' do
|
||||
let(:visitas) do
|
||||
[
|
||||
row(nf: 100, status: 'completed', driver: 'Carlos', sts: 'STS PENHA', checkout: '2026-08-12 09:00:00', notes: '(Avulsa)'),
|
||||
row(nf: 101, status: 'completed', driver: 'Pedro', sts: 'STS PENHA', checkout: '2026-08-11 09:00:00', notes: 'INCLUSÃO'),
|
||||
row(nf: 102, status: 'failed', driver: 'Pedro', sts: 'STS PENHA', checkout: '2026-08-13 09:00:00', notes: 'INCLUSÃO')
|
||||
]
|
||||
end
|
||||
|
||||
it 'separa (Avulsa) de INCLUSÃO' do
|
||||
rotulo, grupos = fora.agrupamento
|
||||
expect(rotulo).to eq('Plano')
|
||||
expect(grupos).to eq([{ nome: 'INCLUSÃO', total: 2 }, { nome: '(Avulsa)', total: 1 }])
|
||||
end
|
||||
end
|
||||
|
||||
# NOT IN com NULL na subquery devolve zero linhas — por isso cada SELECT da
|
||||
# união precisa filtrar nota_fiscal IS NOT NULL.
|
||||
it 'protege o NOT IN contra nota_fiscal NULL' do
|
||||
@@ -55,13 +92,14 @@ RSpec.describe Analytics::NotasForaOperacao do
|
||||
it 'não quebra e informa vazio' do
|
||||
expect(fora.total).to eq(0)
|
||||
expect(fora.any?).to be(false)
|
||||
expect(fora.por_plano).to eq([])
|
||||
expect(fora.agrupamento.last).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
def row(nf:, status:, driver:, checkout: nil, planned: nil, title: nil, obs: nil)
|
||||
def row(nf:, status:, driver:, sts: nil, checkout: nil, planned: nil, title: nil, notes: nil, obs: nil)
|
||||
{ 'reference_id' => nf, 'status' => status, 'driver' => driver,
|
||||
'checkout' => checkout, 'planned_date' => planned, 'observation' => obs,
|
||||
'contact_name' => nil, 'title' => title, 'notes' => nil, 'comments' => nil, 'route_id' => nil }
|
||||
'contact_name' => sts, 'title' => title, 'notes' => notes,
|
||||
'comments' => nil, 'route_id' => nil }
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user