diff --git a/Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx b/Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx new file mode 100644 index 0000000..9cb2e71 Binary files /dev/null and b/Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx differ diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb index 710d102..3dcac82 100644 --- a/app/controllers/operacoes_dashboard_controller.rb +++ b/app/controllers/operacoes_dashboard_controller.rb @@ -10,6 +10,8 @@ class OperacoesDashboardController < ApplicationController # Linhas por página da tabela espelho (planilha da operação) ESPELHO_POR_PAGINA = 15 + XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze + def index authorize :dashboard, :operacoes? @@ -35,8 +37,53 @@ class OperacoesDashboardController < ApplicationController @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao 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 private diff --git a/app/services/analytics/planilha_entregas.rb b/app/services/analytics/planilha_entregas.rb new file mode 100644 index 0000000..a9cca6f --- /dev/null +++ b/app/services/analytics/planilha_entregas.rb @@ -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 diff --git a/app/services/analytics/planilha_entregas_xlsx.rb b/app/services/analytics/planilha_entregas_xlsx.rb new file mode 100644 index 0000000..23b51ab --- /dev/null +++ b/app/services/analytics/planilha_entregas_xlsx.rb @@ -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 diff --git a/app/views/operacoes_dashboard/_espelho.html.erb b/app/views/operacoes_dashboard/_espelho.html.erb index 0bf19d9..d092db6 100644 --- a/app/views/operacoes_dashboard/_espelho.html.erb +++ b/app/views/operacoes_dashboard/_espelho.html.erb @@ -4,7 +4,7 @@ são server-side; o restante da URL (modo/operação/período/cross-filters) é 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) { if status == 'completed' ['Entregue', 'bg-green-500/10 text-green-400 border-green-500/30'] @@ -22,7 +22,7 @@

Espelho por NF — pesquise por nota, motorista, unidade, ocorrência, veículo...

-
+ <% request.query_parameters.except('q', 'pg').each do |k, v| %> <% end %> @@ -37,7 +37,7 @@ Buscar <% 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' %> <% end %>
diff --git a/app/views/operacoes_dashboard/index.html.erb b/app/views/operacoes_dashboard/index.html.erb index 7118ad5..2a0ff56 100644 --- a/app/views/operacoes_dashboard/index.html.erb +++ b/app/views/operacoes_dashboard/index.html.erb @@ -88,16 +88,28 @@ <% end %> - <%# ── Abas de modo ──────────────────────────────────────── %> + <%# ── Abas de modo + atalho para a planilha ─────────────── %> <% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %> -
- <% modos.each do |m, label| %> - <% ativo = @modo == m %> - <%= link_to label, - operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)), - 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 %> +
+
+ <% modos.each do |m, label| %> + <% ativo = @modo == m %> + <%= link_to label, + operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)), + 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 %> +
+ + <%# 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' %>
<% if @tabelas_validas.blank? %> @@ -144,7 +156,6 @@ <% if @metricas %> <%= render 'painel', metricas: @metricas, filtravel: true %> <%= render 'mapa', metricas: @metricas %> - <%= render 'espelho' %> <% end %> <% elsif @modo == 'global' %> @@ -155,7 +166,6 @@ <% if @metricas %> <%= render 'painel', metricas: @metricas, filtravel: true %> <%= render 'mapa', metricas: @metricas %> - <%= render 'espelho' %> <% end %> <% else # comparar %> diff --git a/app/views/operacoes_dashboard/planilha.html.erb b/app/views/operacoes_dashboard/planilha.html.erb new file mode 100644 index 0000000..d1bddcb --- /dev/null +++ b/app/views/operacoes_dashboard/planilha.html.erb @@ -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 %> + +<% end %> + +<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %> +<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %> + +
+ + <%# ── Cabeçalho ─────────────────────────────────────────── %> +
+
+

📋 Planilha da Operação

+

+ <% 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 %> +

+
+ + <%= 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' %> +
+ + <%# ── Seleção de escopo: operação única ou global ───────── %> +
+ <% 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' %> +
+ + + + <% if @busca.present? %><% end %> + +
+ + <%# 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 %> +
+ + <%# ── Filtros ativos herdados do dashboard (chips removíveis) ── %> + <% if @chips.any? %> +
+ Filtros ativos: + <% @chips.each do |chip| %> + + <%= chip[:rotulo] %>: <%= chip[:display] || chip[:valor] %> + <%= link_to '✕', operacoes_planilha_path(request.query_parameters.except(chip[:param], 'pg')), + data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %> + + <% 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' %> +
+ <% end %> + + <%# ── Tabela espelho ─────────────────────────────────────── %> + <% if @metricas %> + <%= render 'espelho' %> + <% else %> +
+ Nenhuma operação encontrada no banco (tabelas gade_entregas_*). +
+ <% end %> +
+ +<%# Troca de operação recarrega a planilha %> + diff --git a/config/routes.rb b/config/routes.rb index 9f867cc..1042fb1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,10 @@ Rails.application.routes.draw do # Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...) 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 get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard