# app/controllers/dashboard_controller.rb class DashboardController < ApplicationController MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre) 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) 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 } # 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 = ces.group(:motorista_nome).distinct.count(:tracking_id) @ranking_consolidado = @fin_por_motorista.map do |nome, valor| { nome: nome, valor: valor, entregas: qtd_por_motorista[nome].to_i } end 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