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

@@ -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