243 lines
12 KiB
Ruby
243 lines
12 KiB
Ruby
# app/services/analytics/planilha_entregas.rb
|
|
#
|
|
# Monta os dados da "planilha Entregas" entregue ao cliente (modelo
|
|
# "Entregas SUDESTE MM.AAAA_FINAL.xlsx"), com as 3 abas do modelo:
|
|
#
|
|
# RESUMO — totais/performance e quadro por coordenadoria (EMAD e UBS),
|
|
# com os motivos de "Não Entregue" agregados do rastreio.
|
|
# ENTREGAS — planilha do cliente (A..V da tabela gade_entregas_*) com as
|
|
# colunas de resultado W..Z preenchidas pelo rastreio (o
|
|
# preenchimento que hoje é feito manualmente).
|
|
# SimpliRoute — dump cru do rastreio (todas as visitas das NFs da operação).
|
|
#
|
|
# 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 da aba ENTREGAS (gade + resultado do rastreio).
|
|
CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze
|
|
|
|
# Aba SimpliRoute: cabeçalho exato do modelo -> colunas candidatas na tabela
|
|
# de rastreio (a primeira que existir é usada; nenhuma existente = vazia).
|
|
RASTREIO_COLUNAS = {
|
|
'Tracking ID' => %w[tracking_id],
|
|
'Referência ID' => %w[reference_id],
|
|
'Data prevista' => %w[planned_date],
|
|
'Motorista' => %w[driver],
|
|
'Co-pilotos' => %w[copilots co_pilotos copilotos],
|
|
'Veículo' => %w[vehicle],
|
|
'Título' => %w[title titulo],
|
|
'Endereço' => %w[address],
|
|
'ETA' => %w[eta],
|
|
'ETD' => %w[etd],
|
|
'Checkin' => %w[checkin],
|
|
'Checkout' => %w[checkout],
|
|
'Responsável' => %w[receiver responsavel responsible],
|
|
'Tempo de serviço estimado' => %w[estimated_service_time tempo_de_servico_estimado duration],
|
|
'Tempo de serviço realme' => %w[real_service_time tempo_de_servico_real],
|
|
'Antecipação' => %w[early antecipacao anticipation],
|
|
'Atraso' => %w[delay atraso],
|
|
'Latitude' => %w[latitude],
|
|
'Longitude' => %w[longitude],
|
|
'Checkout latitude' => %w[checkout_latitude],
|
|
'Checkout longitude' => %w[checkout_longitude],
|
|
'Load' => %w[load load_1 carga],
|
|
'Load 2' => %w[load_2],
|
|
'Load 3' => %w[load_3],
|
|
'Load 4' => %w[load_4],
|
|
'Estado' => %w[status],
|
|
'Comentários' => %w[checkout_comment comments comentarios comment],
|
|
'Observações' => %w[observation observacoes],
|
|
'Janela de horário Inicial 1' => %w[window_start window_start_1 janela_inicial_1],
|
|
'Janela de horário Final 1' => %w[window_end window_end_1 janela_final_1],
|
|
'Janela de horário Inicial 2' => %w[window_start_2 janela_inicial_2],
|
|
'Janela de horário Final 2' => %w[window_end_2 janela_final_2],
|
|
'Habilidades necessárias' => %w[skills_required habilidades_necessarias],
|
|
'Habilidades adicionais' => %w[skills_optional habilidades_adicionais],
|
|
'Notas' => %w[notes notas],
|
|
'Nome de contato' => %w[contact_name],
|
|
'Telefone de contato' => %w[contact_phone telefone_de_contato],
|
|
'Correio eletrônico de contato' => %w[contact_email correio_eletronico_de_contato],
|
|
'ID da rota' => %w[route_id id_da_rota],
|
|
'ID da conta' => %w[account_id],
|
|
'Nome da conta' => %w[account_name nome_da_conta],
|
|
'Nota Fiscal' => %w[nota_fiscal],
|
|
'Termo de Recebimento' => %w[termo_de_recebimento],
|
|
'Foto da Fachada' => %w[foto_da_fachada],
|
|
'Relatório de visita' => %w[relatorio_de_visita visit_report],
|
|
'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base]
|
|
}.freeze
|
|
|
|
# Quadro do RESUMO — linhas fixas do modelo.
|
|
COORDENADORIAS = ['CRS Sudeste', 'CRS Sul', 'CRS Leste', 'CRS Norte', 'CRS Oeste', 'CRS Centro'].freeze
|
|
MOTIVOS = ['Óbito', 'Responsável Ausente', 'Endereço não localizado', 'Recusa',
|
|
'Paciente não reside no local', 'Outros'].freeze
|
|
GRUPOS = %w[EMAD UBS].freeze
|
|
|
|
MESES_PT = %w[JANEIRO FEVEREIRO MARÇO ABRIL MAIO JUNHO JULHO AGOSTO SETEMBRO OUTUBRO NOVEMBRO DEZEMBRO].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
|
|
|
|
# ── Aba ENTREGAS ─────────────────────────────────────────────
|
|
# 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
|
|
@linhas ||= begin
|
|
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
|
|
end
|
|
|
|
# ── Aba SimpliRoute ──────────────────────────────────────────
|
|
# Todas as visitas do rastreio para as NFs da operação (inclui repetidas,
|
|
# como no dump original do SimpliRoute).
|
|
def linhas_rastreio
|
|
gade = conn.quote_table_name(@tabela)
|
|
sql = <<~SQL
|
|
SELECT r.*
|
|
FROM #{conn.quote_table_name(Entrega.table_name)} r
|
|
WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
|
|
ORDER BY r.reference_id, r.checkout NULLS LAST
|
|
SQL
|
|
conn.select_all(sql).to_a
|
|
end
|
|
|
|
# { cabeçalho do modelo => coluna real do rastreio (ou nil) }.
|
|
def colunas_rastreio
|
|
existentes = conn.columns(Entrega.table_name).map(&:name)
|
|
RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
|
|
end
|
|
|
|
# ── Aba RESUMO ───────────────────────────────────────────────
|
|
# Agrega as linhas da ENTREGAS por grupo (EMAD/UBS) e coordenadoria:
|
|
# previsão/entregue/não entregue/pendente + motivos de "Não Entregue".
|
|
def resumo
|
|
grupos = GRUPOS.index_with { Hash.new { |h, k| h[k] = novo_bloco } }
|
|
|
|
linhas.each do |l|
|
|
bloco = grupos[grupo_de(l)][coordenadoria_de(l)]
|
|
bloco[:previsao] += 1
|
|
status = l['rastreio_status'].to_s
|
|
if status == 'completed'
|
|
bloco[:entregue] += 1
|
|
elsif Entrega::STATUS_FALHA.include?(status)
|
|
bloco[:nao_entregue] += 1
|
|
bloco[:motivos][motivo_de(l['rastreio_observation'])] += 1
|
|
else
|
|
bloco[:pendente] += 1
|
|
end
|
|
end
|
|
|
|
total_prev = linhas.size
|
|
total_real = grupos.values.sum { |coords| coords.values.sum { |b| b[:entregue] } }
|
|
{
|
|
mes: "#{MESES_PT[Date.current.month - 1]}/#{Date.current.year}",
|
|
total_previsto: total_prev,
|
|
total_realizado: total_real,
|
|
performance: (total_prev.zero? ? nil : total_real.to_f / total_prev),
|
|
grupos: grupos
|
|
}
|
|
end
|
|
|
|
# Bloco zerado do quadro RESUMO (também usado pelo gerador xlsx).
|
|
def novo_bloco
|
|
{ previsao: 0, entregue: 0, nao_entregue: 0, pendente: 0, motivos: Hash.new(0) }
|
|
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
|
|
|
|
# Grupo do quadro (EMAD/UBS): coluna "operacao" da linha; sem ela, deduz do
|
|
# nome/label da operação. Default UBS (grupo mais comum no modelo).
|
|
def grupo_de(linha)
|
|
texto = I18n.transliterate("#{linha['operacao']} #{@tabela} #{label}").upcase
|
|
texto.include?('EMAD') ? 'EMAD' : 'UBS'
|
|
end
|
|
|
|
# Casa a coordenadoria da linha com as fixas do modelo (sem acento/caixa);
|
|
# valores fora da lista entram como estão (viram linha extra no quadro).
|
|
def coordenadoria_de(linha)
|
|
bruto = linha['coordenadoria'].to_s.strip
|
|
return COORDENADORIAS.first if bruto.empty? # sem coluna: agrega na 1ª (Sudeste)
|
|
|
|
chave = I18n.transliterate(bruto).upcase
|
|
COORDENADORIAS.find { |c| I18n.transliterate(c).upcase == chave } || bruto
|
|
end
|
|
|
|
# Classifica a observação do insucesso nos motivos fixos do RESUMO.
|
|
def motivo_de(observacao)
|
|
o = I18n.transliterate(observacao.to_s).downcase
|
|
return 'Óbito' if o.include?('obito')
|
|
return 'Responsável Ausente' if o.include?('ausente')
|
|
return 'Endereço não localizado' if o.include?('localizado') || o.include?('endereco')
|
|
return 'Recusa' if o.include?('recus')
|
|
return 'Paciente não reside no local' if o.include?('reside')
|
|
'Outros'
|
|
end
|
|
end
|
|
end
|