Adição da opção de baixar a planilha preenchida para o cliente

This commit is contained in:
2026-07-13 18:33:17 -03:00
parent 75d82227c9
commit 1d4bed785d
8 changed files with 337 additions and 15 deletions

View File

@@ -10,6 +10,8 @@ class OperacoesDashboardController < ApplicationController
# Linhas por página da tabela espelho (planilha da operação) # Linhas por página da tabela espelho (planilha da operação)
ESPELHO_POR_PAGINA = 15 ESPELHO_POR_PAGINA = 15
XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze
def index def index
authorize :dashboard, :operacoes? authorize :dashboard, :operacoes?
@@ -35,8 +37,53 @@ class OperacoesDashboardController < ApplicationController
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
end end
end
montar_tabela_espelho if @metricas && %w[operacao global].include?(@modo) # Planilha da operação (tabela espelho): página própria com busca e paginação.
# Aceita os mesmos parâmetros do index (operacao/modo global/período/cross-
# filters), então o botão no dashboard preserva o contexto atual.
def planilha
authorize :dashboard, :operacoes?
@periodo_inicio, @periodo_fim = periodo_selecionado
@modo = params[:modo] == 'global' ? 'global' : 'operacao'
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@tabelas_validas = Operacao.nomes_validos
@filtros, @chips = cross_filtros
@data_filtro = parse_data(params[:f_data])
if @modo == 'global'
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
else
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
end
montar_tabela_espelho if @metricas
end
# GET /dashboard/operacoes/planilha/baixar?operacao=gade_entregas_...
# Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado
# do rastreio — o modelo que é entregue ao cliente.
def baixar_planilha
authorize :dashboard, :operacoes?
tabela = params[:operacao].to_s
unless Operacao.valida?(tabela)
return redirect_to(operacoes_planilha_path, alert: 'Selecione uma operação válida.')
end
planilha = Analytics::PlanilhaEntregas.new(tabela)
linhas = planilha.linhas
if linhas.empty?
return redirect_to(operacoes_planilha_path(operacao: tabela),
alert: "A operação #{planilha.label} não tem registros.")
end
send_data Analytics::PlanilhaEntregasXlsx.new(linhas).gerar,
filename: "Entregas #{planilha.label} #{Date.current.strftime('%m.%Y')}.xlsx",
type: XLSX_MIME,
disposition: 'attachment'
end end
private private

View File

@@ -0,0 +1,93 @@
# app/services/analytics/planilha_entregas.rb
#
# Monta as linhas da "planilha Entregas" entregue ao cliente (modelo
# "Entregas SUDESTE MM.AAAA_FINAL.xlsx", aba ENTREGAS): as colunas A..V vêm da
# própria tabela da operação (gade_entregas_* — espelho da planilha original) e
# as colunas de resultado W..Z (STATUS/ENTREGA/DATA OCORRÊNCIA/OCORRÊNCIA) são
# preenchidas com o ÚLTIMO status de cada NF no rastreio — o preenchimento que
# hoje é feito manualmente.
#
# SEGURANÇA: tabela passa pela whitelist (Operacao.sanitizar) + quote_table_name;
# colunas são whitelist fixa e as ausentes viram NULL (mesmo padrão do
# OperacaoMetricas). Bases SOMENTE LEITURA.
module Analytics
class PlanilhaEntregas
# Colunas da tabela gade (snake_case) -> cabeçalho exato do modelo (A..V).
# Coluna que não existir em alguma operação sai vazia, preservando o layout.
COLUNAS_GADE = {
'ordem' => 'Ordem',
'operacao' => 'Operação',
'coordenadoria' => 'Coordenadoria',
'supervisao' => 'Supervisão',
'unidade' => 'Unidade',
'data_cadastro' => 'Data Cadastro',
'cartao_sus' => 'Cartão SUS',
'nome_completo' => 'Nome Completo',
'endereco_completo' => 'Endereço Completo',
'nome_responsavel' => 'Nome Responsável',
'telefones' => 'Telefones',
'area_de_risco' => 'Área de Risco',
'numero_ativo' => 'Numero Ativo',
'num_serie_base' => 'Num Serie Base',
'num_serie_pulverizador' => 'Num Serie Pulverizador',
'observacoes' => 'Observações',
'endereco_sem_complemento' => 'Endereço Sem Complemento',
'complemento' => 'Complemento',
'latitude' => 'Latitude',
'longitude' => 'Longitude',
'status' => 'STATUS',
'nota_fiscal' => 'NOTA FISCAL'
}.freeze
# Cabeçalho completo A..Z (gade + resultado do rastreio).
CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze
attr_reader :tabela
def initialize(tabela)
@tabela = Operacao.sanitizar([tabela]).first
raise ArgumentError, "Operação inválida: #{tabela}" unless @tabela
end
def label
Operacao.label(@tabela)
end
# Array de hashes: chaves de COLUNAS_GADE + rastreio_status/checkout/observation.
# ORDER BY g.ctid preserva a ordem física de importação — a mesma ordem da
# planilha original do cliente (tabelas gade são read-only após o import).
def linhas
gade = conn.quote_table_name(@tabela)
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
)
SELECT #{selects_gade},
r.status AS rastreio_status,
r.checkout AS rastreio_checkout,
r.observation AS rastreio_observation
FROM #{gade} g
LEFT JOIN ultimo r ON r.rn = 1 AND r.reference_id::text = g.nota_fiscal
ORDER BY g.ctid
SQL
conn.select_all(sql).to_a
end
private
def conn
ActiveRecord::Base.connection
end
# SELECT das colunas whitelisted; ausentes viram NULL com o mesmo alias.
def selects_gade
existentes = conn.columns(@tabela).map(&:name)
COLUNAS_GADE.keys.map do |c|
existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}"
end.join(', ')
end
end
end

View File

@@ -0,0 +1,60 @@
# app/services/analytics/planilha_entregas_xlsx.rb
#
# Gera o binário .xlsx da planilha Entregas do cliente (aba ENTREGAS) a partir
# das linhas montadas por Analytics::PlanilhaEntregas — layout A..Z idêntico ao
# modelo "Entregas SUDESTE MM.AAAA_FINAL.xlsx".
require 'caxlsx'
module Analytics
class PlanilhaEntregasXlsx
def initialize(linhas)
@linhas = linhas
end
# String binária do .xlsx.
def gerar
pkg = Axlsx::Package.new
pkg.workbook.add_worksheet(name: 'ENTREGAS') do |sheet|
negrito = sheet.workbook.styles.add_style(b: true)
sheet.add_row(PlanilhaEntregas::CABECALHO, style: negrito)
@linhas.each do |l|
valores, tipos = linha(l)
sheet.add_row(valores, types: tipos)
end
sheet.auto_filter = "A1:Z#{@linhas.size + 1}"
end
pkg.to_stream.read
end
private
# A..V direto da tabela gade (texto); W..Z do rastreio:
# W STATUS (completed/failed...), X ENTREGA (Sim/Não), Y DATA OCORRÊNCIA
# (data real do checkout), Z OCORRÊNCIA (motivo do insucesso).
def linha(l)
status_r = l['rastreio_status'].to_s
entregue = if status_r == 'completed'
'Sim'
elsif Entrega::STATUS_FALHA.include?(status_r)
'Não'
else
''
end
data_ocorrencia = data_de(l['rastreio_checkout'])
valores = PlanilhaEntregas::COLUNAS_GADE.keys.map { |c| l[c].to_s } +
[status_r, entregue, data_ocorrencia || '', l['rastreio_observation'].to_s]
tipos = Array.new(PlanilhaEntregas::COLUNAS_GADE.size + 2, :string) +
[(data_ocorrencia ? :date : :string), :string]
[valores, tipos]
end
def data_de(valor)
return nil if valor.nil?
return valor.to_date if valor.respond_to?(:to_date)
Date.parse(valor.to_s)
rescue ArgumentError, TypeError
nil
end
end
end

View File

@@ -4,7 +4,7 @@
são server-side; o restante da URL (modo/operação/período/cross-filters) são server-side; o restante da URL (modo/operação/período/cross-filters)
é preservado nos links e no form. %> é preservado nos links e no form. %>
<% pg_path = ->(n) { operacoes_dashboard_path(request.query_parameters.merge('pg' => n)) } %> <% pg_path = ->(n) { operacoes_planilha_path(request.query_parameters.merge('pg' => n)) } %>
<% resultado_badge = ->(status) { <% resultado_badge = ->(status) {
if status == 'completed' if status == 'completed'
['Entregue', 'bg-green-500/10 text-green-400 border-green-500/30'] ['Entregue', 'bg-green-500/10 text-green-400 border-green-500/30']
@@ -22,7 +22,7 @@
<p class="text-gray-400 text-sm">Espelho por NF — pesquise por nota, motorista, unidade, ocorrência, veículo...</p> <p class="text-gray-400 text-sm">Espelho por NF — pesquise por nota, motorista, unidade, ocorrência, veículo...</p>
</div> </div>
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2"> <form method="get" action="<%= operacoes_planilha_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
<% request.query_parameters.except('q', 'pg').each do |k, v| %> <% request.query_parameters.except('q', 'pg').each do |k, v| %>
<input type="hidden" name="<%= k %>" value="<%= v %>"> <input type="hidden" name="<%= k %>" value="<%= v %>">
<% end %> <% end %>
@@ -37,7 +37,7 @@
Buscar Buscar
</button> </button>
<% if @busca.present? %> <% if @busca.present? %>
<%= link_to '✕ Limpar', operacoes_dashboard_path(request.query_parameters.except('q', 'pg')), <%= link_to '✕ Limpar', operacoes_planilha_path(request.query_parameters.except('q', 'pg')),
data: { turbo: false }, class: 'text-xs text-orange-400 hover:text-orange-300 whitespace-nowrap' %> data: { turbo: false }, class: 'text-xs text-orange-400 hover:text-orange-300 whitespace-nowrap' %>
<% end %> <% end %>
</form> </form>

View File

@@ -88,9 +88,10 @@
<% end %> <% end %>
</div> </div>
<%# ── Abas de modo ──────────────────────────────────────── %> <%# ── Abas de modo + atalho para a planilha ─────────────── %>
<% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %> <% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %>
<div class="flex flex-wrap items-center gap-2 border-b border-white/5 pb-3"> <div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 pb-3">
<div class="flex flex-wrap items-center gap-2">
<% modos.each do |m, label| %> <% modos.each do |m, label| %>
<% ativo = @modo == m %> <% ativo = @modo == m %>
<%= link_to label, <%= link_to label,
@@ -100,6 +101,17 @@
<% end %> <% end %>
</div> </div>
<%# Planilha da operação (tabela espelho) — leva o contexto atual junto %>
<% planilha_ctx = request.query_parameters
.slice('f_driver', 'f_sts', 'f_status', 'f_obs', 'f_resultado', 'f_data')
.merge(modo: (@modo == 'global' ? 'global' : 'operacao'), inicio: ini_iso, fim: fim_iso) %>
<% planilha_ctx[:operacao] = @operacao if @operacao %>
<%= link_to '📋 Planilha da operação', operacoes_planilha_path(planilha_ctx),
data: { turbo: false },
class: 'px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap bg-[#1a1a1a]
text-orange-400 border border-orange-500/40 hover:bg-orange-500 hover:text-black' %>
</div>
<% if @tabelas_validas.blank? %> <% if @tabelas_validas.blank? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-10 text-center text-gray-400"> <div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-10 text-center text-gray-400">
Nenhuma operação encontrada no banco (tabelas <code class="text-orange-400">gade_entregas_*</code>). Nenhuma operação encontrada no banco (tabelas <code class="text-orange-400">gade_entregas_*</code>).
@@ -144,7 +156,6 @@
<% if @metricas %> <% if @metricas %>
<%= render 'painel', metricas: @metricas, filtravel: true %> <%= render 'painel', metricas: @metricas, filtravel: true %>
<%= render 'mapa', metricas: @metricas %> <%= render 'mapa', metricas: @metricas %>
<%= render 'espelho' %>
<% end %> <% end %>
<% elsif @modo == 'global' %> <% elsif @modo == 'global' %>
@@ -155,7 +166,6 @@
<% if @metricas %> <% if @metricas %>
<%= render 'painel', metricas: @metricas, filtravel: true %> <%= render 'painel', metricas: @metricas, filtravel: true %>
<%= render 'mapa', metricas: @metricas %> <%= render 'mapa', metricas: @metricas %>
<%= render 'espelho' %>
<% end %> <% end %>
<% else # comparar %> <% else # comparar %>

View File

@@ -0,0 +1,108 @@
<%# app/views/operacoes_dashboard/planilha.html.erb %>
<%# Página da Planilha da Operação: tabela espelho (uma linha por NF) com busca
e paginação. Chega-se aqui pelo botão no Dashboard de Operações, que passa
o contexto (operação/modo global/período/cross-filters) na URL. %>
<% content_for :head do %>
<meta name="turbo-cache-control" content="no-cache">
<% end %>
<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %>
<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %>
<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">📋 Planilha da Operação</h1>
<p class="text-gray-400 text-sm mt-0.5">
<% if @modo == 'global' %>
🌐 Global · <%= @periodo_inicio.strftime('%d/%m/%Y') %> <%= @periodo_fim.strftime('%d/%m/%Y') %>
<% elsif @metricas&.data_inicio %>
<%= @metricas.operacoes_label %> · <%= @metricas.data_inicio.strftime('%d/%m/%Y') %> <%= @metricas.data_fim.strftime('%d/%m/%Y') %>
<% elsif @operacao %>
<%= Operacao.label(@operacao) %>
<% end %>
</p>
</div>
<%= link_to '← Dashboard de Operações',
operacoes_dashboard_path({ modo: @modo, operacao: @operacao, inicio: ini_iso, fim: fim_iso }.compact),
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>
<%# ── Seleção de escopo: operação única ou global ───────── %>
<div class="flex flex-wrap items-center gap-2">
<% pills = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global' } %>
<% pills.each do |m, label| %>
<% ativo = @modo == m %>
<%= link_to label,
operacoes_planilha_path({ modo: m, operacao: @operacao, inicio: ini_iso, fim: fim_iso, q: @busca.presence }.compact),
data: { turbo: false },
class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %>
<% end %>
<% if @modo == 'operacao' %>
<form method="get" action="<%= operacoes_planilha_path %>" data-turbo="false" class="flex items-center gap-2">
<input type="hidden" name="modo" value="operacao">
<input type="hidden" name="inicio" value="<%= ini_iso %>">
<input type="hidden" name="fim" value="<%= fim_iso %>">
<% if @busca.present? %><input type="hidden" name="q" value="<%= @busca %>"><% end %>
<select name="operacao" id="planilha-operacao"
class="bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500 min-w-[240px]">
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @operacao %>><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</form>
<%# Planilha Entregas preenchida (modelo entregue ao cliente) %>
<% if @operacao %>
<%= link_to '⬇️ Baixar Excel preenchido', operacoes_planilha_baixar_path(operacao: @operacao),
data: { turbo: false },
title: 'Planilha Entregas da operação com status/ocorrências preenchidos pelo rastreio',
class: 'px-4 py-2.5 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl text-sm whitespace-nowrap' %>
<% end %>
<% end %>
</div>
<%# ── Filtros ativos herdados do dashboard (chips removíveis) ── %>
<% if @chips.any? %>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-gray-500 uppercase tracking-wider">Filtros ativos:</span>
<% @chips.each do |chip| %>
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-300 text-xs">
<%= chip[:rotulo] %>: <strong class="text-white"><%= chip[:display] || chip[:valor] %></strong>
<%= link_to '✕', operacoes_planilha_path(request.query_parameters.except(chip[:param], 'pg')),
data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %>
</span>
<% end %>
<%= link_to 'Limpar tudo',
operacoes_planilha_path(request.query_parameters.except('pg', *@chips.map { |c| c[:param] })),
data: { turbo: false }, class: 'text-xs text-gray-400 hover:text-orange-400 underline' %>
</div>
<% end %>
<%# ── Tabela espelho ─────────────────────────────────────── %>
<% if @metricas %>
<%= render 'espelho' %>
<% else %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-10 text-center text-gray-400">
Nenhuma operação encontrada no banco (tabelas <code class="text-orange-400">gade_entregas_*</code>).
</div>
<% end %>
</div>
<%# Troca de operação recarrega a planilha %>
<script>
(function () {
const sel = document.getElementById('planilha-operacao');
if (sel) sel.addEventListener('change', function () { sel.form.requestSubmit(); });
})();
</script>

View File

@@ -18,6 +18,10 @@ Rails.application.routes.draw do
# Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...) # Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...)
get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard
# Planilha da operação (tabela espelho pesquisável, página própria)
get '/dashboard/operacoes/planilha', to: 'operacoes_dashboard#planilha', as: :operacoes_planilha
# Download .xlsx da planilha Entregas preenchida (modelo entregue ao cliente)
get '/dashboard/operacoes/planilha/baixar', to: 'operacoes_dashboard#baixar_planilha', as: :operacoes_planilha_baixar
# Painel motorista # Painel motorista
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard