423 lines
19 KiB
Ruby
423 lines
19 KiB
Ruby
# app/controllers/dashboard_controller.rb
|
||
class DashboardController < ApplicationController
|
||
MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre)
|
||
|
||
# Tipos lançados EM LOTE: uma linha só de consolidacao_entregas representa N
|
||
# entregas (quantidade = N, valor_aplicado = N × preço — ver
|
||
# Consolidacao#adicionar_termos!). Contá-los por tracking_id valeria 1.
|
||
TIPOS_EM_LOTE = %w[termo termo_especial].freeze
|
||
|
||
# Cor de cada tipo no donut "Composição do custo". Fica AQUI, junto de quem
|
||
# monta @fin_por_tipo, para label, valor e cor viajarem no MESMO hash: a view
|
||
# tinha uma lista fixa de 5 cores para os 7 tipos de
|
||
# ConsolidacaoEntrega::TIPO_CORES e o Chart.js reciclava o array — "Entrega de
|
||
# Termo" saía com a mesma laranja de "Entrega Normal", no mesmo gráfico.
|
||
# `desconto` NÃO entra: ver o comentário em #carregar_dados_financeiro.
|
||
CORES_TIPO = {
|
||
'entrega_normal' => '#f97316',
|
||
'retirada' => '#9a3412',
|
||
'bonus' => '#fed7aa',
|
||
'extraordinaria' => '#9333ea',
|
||
'termo' => '#2563eb',
|
||
'termo_especial' => '#06b6d4'
|
||
}.freeze
|
||
|
||
def index
|
||
# Motorista tem painel próprio — não enxerga o dashboard administrativo
|
||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||
|
||
authorize :dashboard, :ver?
|
||
|
||
# Custo, pagamento e ticket médio são permissão à parte: um perfil de
|
||
# acompanhamento vê a operação sem ver quanto ela custa. Antes o controller
|
||
# usava skip_authorization e o papel `externo` enxergava tudo.
|
||
@ver_financeiro = current_user.pode?('dashboard.financeiro')
|
||
|
||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||
carregar_dados_dashboard
|
||
end
|
||
|
||
# GET /dashboard/relatorio_financeiro?inicio=&fim=&operacoes[]=
|
||
# Relatório financeiro do período (PDF) — mesmos números da tela.
|
||
def relatorio_financeiro
|
||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||
|
||
authorize :dashboard, :financeiro?
|
||
@ver_financeiro = true
|
||
|
||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||
carregar_dados_dashboard
|
||
|
||
op_label = @operacao_filtro.present? ? @operacao_filtro.map { |t| Operacao.label(t) }.join(', ') : 'Todas'
|
||
|
||
dados = {
|
||
custo_total: @fin_custo_total,
|
||
ticket_medio: @fin_ticket_medio,
|
||
pago: @fin_pago,
|
||
pendente: @fin_pendente,
|
||
por_operacao: @fin_por_operacao,
|
||
por_motorista: @fin_por_motorista,
|
||
por_tipo: @fin_por_tipo,
|
||
pagamentos_feitos: @pagamentos_feitos,
|
||
pagamentos_pendentes: @pagamentos_pendentes,
|
||
pag_pago_valor: @pag_pago_valor,
|
||
pag_pago_qtd: @pag_pago_qtd,
|
||
pag_pend_valor: @pag_pend_valor,
|
||
pag_pend_qtd: @pag_pend_qtd
|
||
}
|
||
|
||
pdf = Pdf::RelatorioFinanceiroPeriodoPdf.new(
|
||
periodo_inicio: @periodo_inicio, periodo_fim: @periodo_fim,
|
||
operacao_label: op_label, dados: dados
|
||
)
|
||
|
||
send_data pdf.render,
|
||
filename: "financeiro_#{@periodo_inicio.strftime('%Y%m%d')}_#{@periodo_fim.strftime('%Y%m%d')}.pdf",
|
||
type: 'application/pdf',
|
||
disposition: 'attachment'
|
||
end
|
||
|
||
# GET /dashboard/pendentes?inicio=&fim=&operacoes[]=
|
||
# Quais são as entregas EM ABERTO do período — o card do dashboard mostrava só
|
||
# o número e não havia tela nenhuma listando as linhas por trás dele.
|
||
#
|
||
# Em aberto = nem concluída nem falhada (Entrega.pendentes), pela data
|
||
# PLANEJADA: é o único recorte sem checkout, então planned_date é o eixo
|
||
# possível. Mesmo recorte do card, para o total bater.
|
||
def pendentes
|
||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||
|
||
authorize :dashboard, :pendentes?
|
||
|
||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
|
||
|
||
escopo = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim)
|
||
escopo = escopo.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||
|
||
@total_em_aberto = escopo.count
|
||
begin
|
||
@pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50)
|
||
rescue Pagy::OverflowError
|
||
@pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50, page: 1)
|
||
end
|
||
|
||
# Contexto de cada NF listada: em que operação ela está (se estiver) e
|
||
# quantas visitas o rastreio tem para ela. As duas respostas juntas dizem por
|
||
# que a entrega ficou em aberto — e por que o dashboard de Operações não a
|
||
# mostra: ou é nota fora da operação, ou tem outra visita que a substituiu.
|
||
nfs = @em_aberto.map { |e| e.reference_id.to_s }.reject(&:empty?)
|
||
@operacao_por_nf = Operacao.por_notas(nfs)
|
||
@visitas_por_nf = nfs.any? ? Entrega.where(reference_id: nfs).group(:reference_id).count : {}
|
||
end
|
||
|
||
private
|
||
|
||
# Faixa de datas vinda do filtro de calendário (params inicio/fim).
|
||
# Default: do início do mês corrente até hoje. Datas inválidas caem no default
|
||
# e a ordem é normalizada caso venham invertidas.
|
||
def periodo_selecionado
|
||
inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month
|
||
fim = parse_data(params[:fim]) || Date.current
|
||
inicio, fim = fim, inicio if fim < inicio
|
||
[inicio, fim]
|
||
end
|
||
|
||
# Parse tolerante: data inválida/ausente vira nil (para o default assumir).
|
||
def parse_data(str)
|
||
return nil if str.blank?
|
||
Date.parse(str)
|
||
rescue ArgumentError, TypeError
|
||
nil
|
||
end
|
||
|
||
def carregar_dados_dashboard
|
||
# Filtro de operação (multi-seleção) — aplicado a todo o dashboard.
|
||
@operacoes = Operacao.todas
|
||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
|
||
|
||
# ATENDIDAS pela DATA REAL (checkout) — base de toda a análise financeira.
|
||
#
|
||
# Atendida = o motorista FOI ao local e fechou a visita, com sucesso
|
||
# (`completed`) OU insucesso (`failed`). É exatamente o conjunto que a
|
||
# consolidação considera elegível e paga (ver Entrega::STATUS_ATENDIDO,
|
||
# Entrega.contar_atendidas e Consolidacao#veiculos_do_motorista), por isso o
|
||
# dashboard usa o MESMO recorte — usava só `pagas` (concluídas) e mostrava
|
||
# um valor menor que o do fechamento.
|
||
atendidas = Entrega.da_conta_gade.atendidas.no_periodo_checkout(@periodo_inicio, @periodo_fim)
|
||
atendidas = atendidas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||
|
||
# PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada —
|
||
# é o único recorte sem checkout, então planned_date é o eixo correto aqui.
|
||
pendentes = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim)
|
||
pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||
|
||
# Quebra do total entre sucesso e insucesso (ambos já dentro de `atendidas`).
|
||
# ⚠️ Falhada TEM checkout: o motorista foi ao local e fechou a visita com
|
||
# motivo de insucesso. Por isso o eixo é checkout, igual às concluídas — antes
|
||
# as falhas eram filtradas por planned_date e caíam no período errado.
|
||
@entregas_pagas = atendidas.concluidas.count
|
||
@entregas_falhadas = atendidas.falhadas.count
|
||
@entregas_atendidas = @entregas_pagas + @entregas_falhadas
|
||
@entregas_pendentes = pendentes.count
|
||
@total_entregas = @entregas_atendidas + @entregas_pendentes
|
||
|
||
# Quantas NOTAS FISCAIS distintas estão por trás das visitas atendidas. É o
|
||
# número que o dashboard de Operações mostra (e que o cliente paga/confere
|
||
# nos documentos); aqui contamos VISITAS, porque é por ida ao local que o
|
||
# motorista recebe. Exibir os dois lado a lado evita a leitura de que uma das
|
||
# telas está errada quando elas divergem — a diferença são retentativas.
|
||
@notas_atendidas = atendidas.distinct.count(:reference_id)
|
||
|
||
# Configurações de preço
|
||
config = Configuracao.mapa_de_precos
|
||
|
||
# Valor estimado total
|
||
@valor_estimado = (@entregas_atendidas * config[:entrega]).round(2)
|
||
|
||
# Por motorista (todos)
|
||
@motoristas = atendidas.group(:driver)
|
||
.count
|
||
.sort_by { |_, v| -v }
|
||
.map do |driver, qtd|
|
||
{
|
||
nome: driver,
|
||
entregas: qtd,
|
||
valor: (qtd * config[:entrega]).round(2)
|
||
}
|
||
end
|
||
|
||
# Por local de entrega (contact_name)
|
||
@por_operacao = atendidas.group(:contact_name)
|
||
.count
|
||
.sort_by { |_, v| -v }
|
||
.first(6)
|
||
.to_h
|
||
|
||
# Evolução diária no período (para Chart.js) — por data real (checkout)
|
||
@grafico_diario = build_grafico_diario(atendidas, config[:entrega])
|
||
|
||
# Consolidações cujo período (data_inicio..data_fim) cruza o período do dashboard
|
||
# — independe de quando foram criadas (created_at).
|
||
@consolidacoes_mes = Consolidacao.ativas
|
||
.where('data_inicio <= ? AND data_fim >= ?', @periodo_fim, @periodo_inicio)
|
||
@consolidacoes_mes = @consolidacoes_mes.com_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
|
||
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
|
||
|
||
# Pagamentos e visão financeira só são consultados por quem pode vê-los: sem a
|
||
# permissão, o dado não chega à página (a view esconde os blocos, mas o certo
|
||
# é nem carregar — é consulta cara e é informação de custo).
|
||
if @ver_financeiro
|
||
carregar_dados_pagamentos
|
||
carregar_dados_financeiro
|
||
end
|
||
|
||
# Histórico estimado mais recente
|
||
@historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5)
|
||
end
|
||
|
||
def carregar_dados_pagamentos
|
||
intervalo = @periodo_inicio.beginning_of_day..@periodo_fim.end_of_day
|
||
|
||
# PAGOS: filtrados pela DATA DO PAGAMENTO (pago_em) dentro do período do topo
|
||
# — "quanto foi pago neste período".
|
||
pagos = ConsolidacaoMotorista.ativos
|
||
.joins(:consolidacao)
|
||
.where(consolidacoes: { status: :finalizada, deleted_at: nil })
|
||
.where(pago_em: intervalo)
|
||
if @operacao_filtro.present?
|
||
pagos = pagos.where(consolidacao_id: Consolidacao.com_operacoes(@operacao_filtro).select(:id))
|
||
end
|
||
@pag_pago_qtd = pagos.count
|
||
@pag_pago_valor = pagos.sum(:valor_total)
|
||
|
||
# PENDENTES: consolidações finalizadas cujo PERÍODO cruza o filtro, ainda sem
|
||
# pagamento (pendente não tem data de pagamento para filtrar).
|
||
finalizadas_ids = @consolidacoes_mes.where(status: :finalizada).select(:id)
|
||
pendentes = ConsolidacaoMotorista.ativos.where(consolidacao_id: finalizadas_ids).pendentes
|
||
@pag_pend_qtd = pendentes.count
|
||
@pag_pend_valor = pendentes.sum(:valor_total)
|
||
|
||
@pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc)
|
||
@pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :desc)
|
||
|
||
# Fluxo de pagamentos por dia (valor pago + acumulado) para o gráfico
|
||
@grafico_pagamentos_dia = build_grafico_pagamentos(pagos)
|
||
end
|
||
|
||
# Visão financeira (custo) das consolidações finalizadas do período, já com o
|
||
# filtro de operação aplicado.
|
||
def carregar_dados_financeiro
|
||
intervalo = @periodo_inicio.beginning_of_day..@periodo_fim.end_of_day
|
||
|
||
base = Consolidacao.ativas.where(status: :finalizada)
|
||
base = base.com_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||
|
||
# Consolidações relevantes ao período: as que CRUZAM o período OU que tiveram
|
||
# algum pagamento feito dentro do período. Sem o 2º caso, pagar uma
|
||
# consolidação de outro mês não mexia nos gráficos (só na tabela de pagamentos).
|
||
ids_periodo = base.where('data_inicio <= ? AND data_fim >= ?', @periodo_fim, @periodo_inicio).ids
|
||
ids_pagos = ConsolidacaoMotorista.ativos.where(pago_em: intervalo)
|
||
.where(consolidacao_id: base.select(:id))
|
||
.distinct.pluck(:consolidacao_id)
|
||
|
||
consols = Consolidacao.where(id: (ids_periodo + ids_pagos).uniq).to_a
|
||
ids = consols.map(&:id)
|
||
cms = ConsolidacaoMotorista.ativos.where(consolidacao_id: ids)
|
||
|
||
# ⚠️ O MESMO RECORTE DE `cms`, NO NÍVEL DA LINHA. Motorista arquivado sai do
|
||
# dinheiro (ConsolidacaoMotorista.ativos), mas as linhas dele CONTINUAM em
|
||
# consolidacao_entregas: `arquivar_motorista` é reversível e preserva tudo —
|
||
# quem apaga é o `excluir_motorista`. Sem este EXISTS, o card "Custo total"
|
||
# mostrava R$ de um conjunto e "N entregas classificadas" de outro, o ticket
|
||
# médio dividia um pelo outro e o donut de composição não fechava com o total.
|
||
#
|
||
# O par é (consolidacao_id, motorista_nome), não só o nome: o mesmo motorista
|
||
# pode estar ativo numa consolidação e arquivado em outra, ambas no período.
|
||
ces = ConsolidacaoEntrega.where(consolidacao_id: ids).where(<<~SQL.squish)
|
||
EXISTS (SELECT 1 FROM consolidacao_motoristas cm
|
||
WHERE cm.consolidacao_id = consolidacao_entregas.consolidacao_id
|
||
AND cm.motorista_nome = consolidacao_entregas.motorista_nome
|
||
AND cm.arquivado_em IS NULL)
|
||
SQL
|
||
|
||
# KPIs
|
||
@fin_custo_total = cms.sum(:valor_total)
|
||
@fin_qtd_entregas = contar_entregas(ces)
|
||
@fin_ticket_medio = @fin_qtd_entregas.zero? ? 0 : (@fin_custo_total / @fin_qtd_entregas)
|
||
@fin_pago = cms.pagos.sum(:valor_total)
|
||
@fin_pendente = cms.pendentes.sum(:valor_total)
|
||
|
||
# Custo por operação — rateia o valor da consolidação igualmente entre suas
|
||
# operações (mantém o total consistente; exato no caso de 1 operação).
|
||
por_op = Hash.new(0)
|
||
consols.each do |c|
|
||
ops = c.operacoes.presence || ['(sem operação)']
|
||
fatia = c.valor_total / ops.size
|
||
ops.each { |o| por_op[o == '(sem operação)' ? o : Operacao.label(o)] += fatia }
|
||
end
|
||
@fin_por_operacao = por_op.sort_by { |_, v| -v }
|
||
|
||
# Composição por tipo (normal/retirada/bônus/…)
|
||
#
|
||
# ⚠️ DESCONTO NÃO É FATIA. No dinheiro ele SUBTRAI (ConsolidacaoMotorista
|
||
# #recalcular_valor e Consolidacao#recalcular_motorista! usam
|
||
# `-valor_aplicado`). Como fatia positiva de um DONUT — que se lê como
|
||
# "partes de um todo" — a soma das fatias ficava maior que o Custo total
|
||
# mostrado logo acima, e a diferença era exatamente o desconto. Ele sai do
|
||
# gráfico e vira a linha "já abatidos" embaixo: a divergência é legítima,
|
||
# então é explicada no lugar onde ela aparece (diretriz 1).
|
||
soma_tipo = ces.group(:tipo).sum(:valor_aplicado)
|
||
@fin_descontos = soma_tipo['desconto'].to_f
|
||
@fin_por_tipo = CORES_TIPO.filter_map do |tipo, cor|
|
||
valor = soma_tipo[tipo].to_f
|
||
next if valor.zero?
|
||
{ label: ConsolidacaoEntrega::TIPO_CORES.dig(tipo, :label) || tipo, valor: valor, cor: cor }
|
||
end
|
||
|
||
# Custo por motorista (todos)
|
||
@fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total)
|
||
.sort_by { |_, v| -v }
|
||
|
||
# Ranking CONSOLIDADO (aba "Consolidado" do card Motoristas): o valor REAL
|
||
# fechado por motorista + quantas entregas entraram nesse fechamento.
|
||
#
|
||
# ⚠️ A quantidade é DISTINCT tracking_id, NÃO a contagem de linhas: a mesma
|
||
# entrega pode ter vários pilares (Normal + Bônus + Retirada = 3 linhas em
|
||
# consolidacao_entregas), e contar linhas inflaria o número de entregas.
|
||
#
|
||
# Partimos de @fin_por_motorista (que vem de `cms`, já .ativos) e só buscamos
|
||
# a quantidade: assim a aba lista exatamente os motoristas do fechamento —
|
||
# arquivado não aparece — e a soma bate com o KPI "Custo total" do topo.
|
||
qtd_por_motorista = contar_entregas_por_motorista(ces)
|
||
|
||
@ranking_consolidado = @fin_por_motorista.map do |nome, valor|
|
||
{ nome: nome, valor: valor, entregas: qtd_por_motorista[nome].to_i }
|
||
end
|
||
end
|
||
|
||
# Quantas ENTREGAS existem num conjunto de linhas de consolidacao_entregas.
|
||
# São DUAS correções na mesma conta, e as duas precisam conviver:
|
||
#
|
||
# • DISTINCT tracking_id — a MESMA entrega vira várias linhas quando tem mais
|
||
# de um pilar (Normal + Bônus + Retirada = 3 linhas). Contar linhas inflava
|
||
# o número de entregas. Esta parte já estava certa.
|
||
# • termo soma `quantidade` — um lote de termo é UMA linha com quantidade N
|
||
# (Consolidacao#adicionar_termos!), então por tracking_id um lote de 20
|
||
# valia 1: o custo entrava inteiro no numerador e 1 no denominador, e o
|
||
# ticket médio saía 20× maior. É a mesma unidade que
|
||
# Analytics::TotaisPorMotorista já usa ("um termo com quantidade 5 vale 5").
|
||
def contar_entregas(escopo)
|
||
escopo.where.not(tipo: TIPOS_EM_LOTE).distinct.count(:tracking_id) +
|
||
escopo.where(tipo: TIPOS_EM_LOTE).sum(:quantidade)
|
||
end
|
||
|
||
# Mesma conta de #contar_entregas, quebrada por motorista — o ranking e o KPI
|
||
# do topo têm que somar a mesma coisa, senão viram dois números na mesma tela.
|
||
def contar_entregas_por_motorista(escopo)
|
||
normais = escopo.where.not(tipo: TIPOS_EM_LOTE).group(:motorista_nome).distinct.count(:tracking_id)
|
||
lotes = escopo.where(tipo: TIPOS_EM_LOTE).group(:motorista_nome).sum(:quantidade)
|
||
normais.merge(lotes) { |_nome, a, b| a + b }
|
||
end
|
||
|
||
# `atendidas` já vem filtrado por checkout no período (concluídas + falhadas).
|
||
# Agrupa pela DATA REAL (DATE(checkout)) — DATE() sobre timestamp não faz
|
||
# conversão de fuso.
|
||
def build_grafico_diario(atendidas, preco_entrega)
|
||
dias = (@periodo_inicio..@periodo_fim).to_a
|
||
|
||
if dias.size <= MAX_DIAS_GRAFICO
|
||
# Granularidade diária (períodos curtos)
|
||
contagem = atendidas.group("DATE(checkout)").count.transform_keys(&:to_s)
|
||
labels = dias.map { |d| d.strftime('%d/%m') }
|
||
valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) }
|
||
qtds = dias.map { |d| contagem[d.to_s] || 0 }
|
||
else
|
||
# Período longo: agrega por mês para o gráfico não ficar vazio nem poluído.
|
||
contagem = atendidas.group("TO_CHAR(checkout, 'YYYY-MM')").count
|
||
meses = meses_no_periodo
|
||
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
|
||
valores = meses.map { |m| ((contagem[m] || 0) * preco_entrega).round(2) }
|
||
qtds = meses.map { |m| contagem[m] || 0 }
|
||
end
|
||
|
||
{ labels: labels, valores: valores, qtds: qtds }
|
||
end
|
||
|
||
# Valor pago por dia (pela data do pagamento) + acumulado no período,
|
||
# para o gráfico de fluxo de pagamentos.
|
||
def build_grafico_pagamentos(pagos)
|
||
dias = (@periodo_inicio..@periodo_fim).to_a
|
||
|
||
if dias.size <= MAX_DIAS_GRAFICO
|
||
soma = pagos.group("DATE(pago_em)").sum(:valor_total).transform_keys(&:to_s)
|
||
labels = dias.map { |d| d.strftime('%d/%m') }
|
||
chaves = dias.map(&:to_s)
|
||
else
|
||
soma = pagos.group("TO_CHAR(pago_em, 'YYYY-MM')").sum(:valor_total)
|
||
meses = meses_no_periodo
|
||
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
|
||
chaves = meses
|
||
end
|
||
|
||
valores = chaves.map { |k| soma[k].to_f.round(2) }
|
||
acumulado = 0
|
||
{ labels: labels, valores: valores,
|
||
acumulado: valores.map { |v| (acumulado += v).round(2) } }
|
||
end
|
||
|
||
# Meses ("YYYY-MM") do início ao fim do período selecionado, inclusive.
|
||
def meses_no_periodo
|
||
meses = []
|
||
cursor = @periodo_inicio.beginning_of_month
|
||
while cursor <= @periodo_fim
|
||
meses << cursor.strftime('%Y-%m')
|
||
cursor = cursor.next_month
|
||
end
|
||
meses
|
||
end
|
||
end
|