Files
Reem-Notas/app/controllers/dashboard_controller.rb

423 lines
19 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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