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...
- 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' } %> -+ <% 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 %> +
+gade_entregas_*).
+