# app/controllers/dashboard_controller.rb class DashboardController < ApplicationController MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre) def index skip_authorization # Motorista tem painel próprio — não enxerga o dashboard administrativo return redirect_to(motorista_dashboard_path) if current_user.motorista? @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 skip_authorization return redirect_to(motorista_dashboard_path) if current_user.motorista? @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 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]) # PAGAS pela DATA REAL (checkout) — base de toda a análise financeira por data. pagas = Entrega.da_conta_gade.pagas.no_periodo_checkout(@periodo_inicio, @periodo_fim) pagas = pagas.da_operacoes(@operacao_filtro) if @operacao_filtro.present? # PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada. pendentes = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim) pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present? # FALHADAS (insucesso) pela data planejada — também não têm checkout. falhadas = Entrega.da_conta_gade.falhadas.no_periodo(@periodo_inicio, @periodo_fim) falhadas = falhadas.da_operacoes(@operacao_filtro) if @operacao_filtro.present? # Totais gerais @entregas_pagas = pagas.count @entregas_pendentes = pendentes.count @entregas_falhadas = falhadas.count @total_entregas = @entregas_pagas + @entregas_pendentes + @entregas_falhadas # Configurações de preço config = Configuracao.mapa_de_precos # Valor estimado total @valor_estimado = (@entregas_pagas * config[:entrega]).round(2) # Por motorista (todos) @motoristas = pagas.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 = pagas.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(pagas, 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: pago vs pendente entre as consolidações FINALIZADAS do período # (medido por motorista — fonte: consolidacao_motoristas). carregar_dados_pagamentos # Visão financeira por operação (KPIs + gráficos) carregar_dados_financeiro # 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) 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) ces = ConsolidacaoEntrega.where(consolidacao_id: ids) # KPIs @fin_custo_total = cms.sum(:valor_total) @fin_qtd_entregas = ces.distinct.count(:tracking_id) @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) soma_tipo = ces.group(:tipo).sum(:valor_aplicado) @fin_por_tipo = ConsolidacaoEntrega::TIPO_CORES.filter_map do |tipo, cfg| valor = soma_tipo[tipo].to_f next if valor.zero? { label: cfg[:label], valor: valor } end # Custo por motorista (todos) @fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total) .sort_by { |_, v| -v } end # `pagas` já vem filtrado por checkout no período. Agrupa pela DATA REAL # (DATE(checkout)) — DATE() sobre timestamp não faz conversão de fuso. def build_grafico_diario(pagas, preco_entrega) dias = (@periodo_inicio..@periodo_fim).to_a if dias.size <= MAX_DIAS_GRAFICO # Granularidade diária (períodos curtos) contagem = pagas.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 = pagas.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 # 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