diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index b8fe1cd..30a09fa 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -40,6 +40,47 @@ class ConsolidacoesController < ApplicationController end end + # GET /consolidacoes/totais?inicio=&fim=&motorista=&rascunhos=1 + # + # "Quanto cada motorista somou no período". A lista de consolidações mostra um + # card por CONSOLIDAÇÃO, mas o pagamento é por MOTORISTA: para saber quanto um + # motorista fechou no mês era preciso abrir consolidação por consolidação e + # somar à mão. Aqui o total vem pronto e, clicando no motorista (?motorista=), + # a tela abre as consolidações que compõem aquele valor. + def totais + authorize Consolidacao, :index? + + @inicio, @fim = periodo_totais + @incluir_rascunhos = params[:rascunhos] == '1' + @motorista = params[:motorista].presence + @totais = Analytics::TotaisPorMotorista.new( + inicio: @inicio, fim: @fim, incluir_rascunhos: @incluir_rascunhos + ) + # Drill-down: as consolidações do motorista clicado (a tabela geral continua + # na tela, então o service é montado sem recorte de motorista). + @detalhe = @motorista ? @totais.consolidacoes_de(@motorista) : nil + end + + # GET /consolidacoes/totais_pdf — o mesmo relatório em PDF. Com ?motorista= + # sai só o extrato daquele motorista (com espaço de assinatura). + def totais_pdf + authorize Consolidacao, :index? + + inicio, fim = periodo_totais + totais = Analytics::TotaisPorMotorista.new( + inicio: inicio, fim: fim, + motorista: params[:motorista].presence, + incluir_rascunhos: params[:rascunhos] == '1' + ) + + nome = params[:motorista].presence&.parameterize(separator: '_') + arquivo = ['totais', nome, inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].compact.join('_') + send_data Pdf::TotaisMotoristaPdf.new(totais: totais, detalhar: params[:detalhar] != '0').render, + filename: "#{arquivo}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + # GET /consolidacoes/new def new authorize Consolidacao @@ -449,6 +490,24 @@ class ConsolidacoesController < ApplicationController private + # Período do relatório de totais. Default: o MÊS CORRENTE INTEIRO — o fim é o + # último dia do mês, não "hoje", porque o recorte exige a consolidação inteira + # dentro da faixa (Consolidacao.no_periodo) e um fechamento que vai até 31/08 + # sumiria da tela até o dia 31 chegar. + def periodo_totais + inicio = parse_data_totais(params[:inicio]) || Date.current.beginning_of_month + fim = parse_data_totais(params[:fim]) || Date.current.end_of_month + inicio, fim = fim, inicio if fim < inicio + [inicio, fim] + end + + def parse_data_totais(str) + return nil if str.blank? + Date.parse(str) + rescue ArgumentError, TypeError + nil + end + # Acha o User motorista pelo nome para gerar o QR Code de acesso. O # motorista_nome vem dos dados externos de entrega e o users.nome é digitado # no admin — então toleramos diferenças de caixa e de espaços (extras/duplos). diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index 6a1e903..86bb26c 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -54,8 +54,60 @@ module Motorista .includes(:consolidacao) end + # GET /motorista/totais?inicio=&fim= + # + # "Quanto eu fechei neste período e de onde vem esse valor". O card do painel + # mostra só o total acumulado; aqui o motorista escolhe o período, vê o total + # FECHADO (consolidações finalizadas) e a lista de consolidações que compõem + # o valor — a mesma conta que o admin vê em /consolidacoes/totais, montada + # pelo mesmo service para os dois números não poderem divergir. + def totais + @inicio, @fim = periodo_totais + @totais = totais_do_periodo + @consolidacoes = @totais.consolidacoes_de(current_user.nome) + end + + # GET /motorista/totais/pdf — o extrato do período em PDF (com assinatura), + # para o motorista guardar ou mandar por WhatsApp. + def totais_pdf + inicio, fim = periodo_totais + totais = Analytics::TotaisPorMotorista.new( + inicio: inicio, fim: fim, motorista: current_user.nome + ) + + arquivo = ['meu_total', current_user.nome.parameterize(separator: '_'), + inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].join('_') + send_data Pdf::TotaisMotoristaPdf.new(totais: totais).render, + filename: "#{arquivo}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + private + def totais_do_periodo + Analytics::TotaisPorMotorista.new( + inicio: @inicio, fim: @fim, motorista: current_user.nome + ) + end + + # Default: mês corrente INTEIRO (fim = último dia do mês). O recorte pede a + # consolidação inteira dentro da faixa, então terminar em "hoje" esconderia + # um fechamento que vai até o fim do mês. Mesma regra do relatório do admin. + def periodo_totais + inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month + fim = parse_data(params[:fim]) || Date.current.end_of_month + inicio, fim = fim, inicio if fim < inicio + [inicio, fim] + end + + def parse_data(str) + return nil if str.blank? + Date.parse(str) + rescue ArgumentError, TypeError + nil + end + def garantir_motorista return if current_user.motorista? redirect_to dashboard_path, alert: 'Área exclusiva dos motoristas.' diff --git a/app/services/analytics/totais_por_motorista.rb b/app/services/analytics/totais_por_motorista.rb new file mode 100644 index 0000000..3112401 --- /dev/null +++ b/app/services/analytics/totais_por_motorista.rb @@ -0,0 +1,111 @@ +# app/services/analytics/totais_por_motorista.rb +# +# Quanto cada motorista somou no período — e QUAIS consolidações formam esse +# total. É a fonte única das duas telas de "total por motorista": +# +# • admin → /consolidacoes/totais (todos os motoristas + drill-down) +# • motorista→ /motorista/totais (só ele, mesma conta, mesmo PDF) +# +# RECORTE (igual ao filtro da lista de consolidações): entram as consolidações +# ATIVAS cujo período está DENTRO da faixa escolhida (data_inicio >= inicio e +# data_fim <= fim). Não é sobreposição de propósito — uma consolidação que +# atravessa a borda do filtro entraria inteira e o "total do período" ficaria +# maior que o período. +# +# Só motoristas ATIVOS (não arquivados) contam: é o mesmo conjunto que a +# consolidação paga (ConsolidacaoMotorista.ativos), então o total desta tela +# bate com a soma dos fechamentos. +module Analytics + class TotaisPorMotorista + def initialize(inicio:, fim:, motorista: nil, incluir_rascunhos: false) + @inicio = inicio.to_date + @fim = fim.to_date + @motorista = motorista.presence + @incluir_rascunhos = incluir_rascunhos + end + + attr_reader :inicio, :fim, :motorista + + # ConsolidacaoMotorista do recorte, com a consolidação já carregada (as duas + # telas mostram nome/período/status dela em seguida). + def registros + @registros ||= begin + base = Consolidacao.ativas.no_periodo(@inicio, @fim) + # Só o que já foi FECHADO conta como total do período; rascunho ainda + # muda de valor. `merge` (e não where(consolidacoes: {status: ...})) + # porque status é enum: quem sabe traduzir :finalizada é o próprio model. + base = base.finalizada unless @incluir_rascunhos + + escopo = ConsolidacaoMotorista.ativos + .joins(:consolidacao) + .merge(base) + .includes(:consolidacao) + escopo = escopo.where(motorista_nome: @motorista) if @motorista + escopo.to_a + end + end + + # Uma linha por motorista, do maior total para o menor. + def linhas + @linhas ||= registros.group_by(&:motorista_nome).map do |nome, cms| + pagos = cms.select(&:pago?) + pendentes = cms.reject(&:pago?) + { + motorista: nome, + consolidacoes: cms.size, + lancamentos: lancamentos_por_motorista[nome].to_i, + valor_total: soma(cms), + valor_pago: soma(pagos), + valor_pendente: soma(pendentes), + pagas: pagos.size, + pendentes: pendentes.size + } + end.sort_by { |l| -l[:valor_total] } + end + + # Consolidações que compõem o total de UM motorista (o drill-down), da mais + # recente para a mais antiga. + def consolidacoes_de(nome) + registros.select { |cm| cm.motorista_nome == nome } + .sort_by { |cm| [cm.consolidacao.data_fim, cm.consolidacao.data_inicio] } + .reverse + end + + def motoristas + linhas.map { |l| l[:motorista] } + end + + def total_geral = soma(registros) + def total_pago = soma(registros.select(&:pago?)) + def total_pendente = soma(registros.reject(&:pago?)) + def total_consolidacoes = registros.map(&:consolidacao_id).uniq.size + def vazio? = registros.empty? + + def periodo_label + "#{@inicio.strftime('%d/%m/%Y')} a #{@fim.strftime('%d/%m/%Y')}" + end + + private + + def soma(cms) + cms.sum { |cm| cm.valor_total || 0 } + end + + # Lançamentos classificados por motorista (soma da quantidade — um termo com + # quantidade 5 vale 5). Uma query só para todas as consolidações do recorte, + # em vez de uma por linha da tabela. + def lancamentos_por_motorista + @lancamentos_por_motorista ||= begin + ids = registros.map(&:consolidacao_id).uniq + if ids.empty? + {} + else + ConsolidacaoEntrega.where(consolidacao_id: ids, + motorista_nome: registros.map(&:motorista_nome).uniq) + .group(:motorista_nome) + .sum(:quantidade) + end + end + end + end +end diff --git a/app/services/pdf/totais_motorista_pdf.rb b/app/services/pdf/totais_motorista_pdf.rb new file mode 100644 index 0000000..c9e44b8 --- /dev/null +++ b/app/services/pdf/totais_motorista_pdf.rb @@ -0,0 +1,130 @@ +# app/services/pdf/totais_motorista_pdf.rb +# +# Relatório "total por motorista no período" — o mesmo documento serve às duas +# telas, porque os números vêm do mesmo Analytics::TotaisPorMotorista: +# +# • admin (todos os motoristas): resumo por motorista + as consolidações que +# compõem o total de cada um; +# • motorista (um só): o total fechado dele no período + as consolidações que +# formam esse valor, com espaço de assinatura para servir de comprovante. +# +module Pdf + class TotaisMotoristaPdf < BasePdf + VERDE = '22C55E' + AMBAR = 'D97706' + + # totais: Analytics::TotaisPorMotorista já montado (ele conhece o recorte). + # detalhar: inclui, para cada motorista, a lista de consolidações. Sempre + # ligado quando o relatório é de um motorista só. + def initialize(totais:, detalhar: true, titulo: nil) + super() + @t = totais + @detalhar = detalhar || @t.motorista.present? + @titulo = titulo + end + + private + + def corpo + secao(@titulo || (@t.motorista ? 'MEU TOTAL NO PERÍODO' : 'TOTAL POR MOTORISTA')) + + @pdf.text "Período: #{@t.periodo_label}", size: 11, style: :bold + @pdf.fill_color CINZA + @pdf.text(@t.motorista ? "Motorista: #{@t.motorista}" : "Motoristas: #{@t.linhas.size}", size: 10) + @pdf.text "Consolidações no período: #{@t.total_consolidacoes}", size: 10 + @pdf.fill_color '000000' + + secao 'TOTAIS' + kpis + + if @t.vazio? + @pdf.fill_color CINZA + @pdf.text 'Nenhuma consolidação fechada neste período.', size: 10 + @pdf.fill_color '000000' + return + end + + unless @t.motorista + secao 'RESUMO POR MOTORISTA' + tabela_resumo + end + + return unless @detalhar + + @t.linhas.each do |linha| + @pdf.start_new_page if @pdf.cursor < 160 + secao "COMPOSIÇÃO — #{linha[:motorista].upcase}" + tabela_composicao(@t.consolidacoes_de(linha[:motorista])) + end + + assinaturas(motorista: @t.motorista) if @t.motorista + end + + def kpis + linhas = [ + ['Total do período', moeda(@t.total_geral)], + ['Pago', moeda(@t.total_pago)], + ['A receber', moeda(@t.total_pendente)] + ] + @pdf.table(linhas, width: 300, cell_style: { size: 11, padding: [6, 10] }) do |t| + t.columns(0).font_style = :bold + t.columns(1).align = :right + t.row(1).text_color = VERDE + t.row(2).text_color = AMBAR + t.cells.borders = [:bottom] + t.cells.border_color = 'DDDDDD' + end + end + + def tabela_resumo + linhas = [['Motorista', 'Consol.', 'Lanç.', 'Pago', 'A receber', 'Total']] + @t.linhas.each do |l| + linhas << [l[:motorista], l[:consolidacoes].to_s, l[:lancamentos].to_s, + moeda(l[:valor_pago]), moeda(l[:valor_pendente]), moeda(l[:valor_total])] + end + linhas << ['TOTAL', @t.total_consolidacoes.to_s, '', + moeda(@t.total_pago), moeda(@t.total_pendente), moeda(@t.total_geral)] + + @pdf.table(linhas, header: true, width: @pdf.bounds.width, + column_widths: { 1 => 50, 2 => 50, 3 => 80, 4 => 80, 5 => 85 }, + cell_style: { size: 8, padding: [4, 6] }) do |t| + t.row(0).background_color = PRETO + t.row(0).text_color = 'FFFFFF' + t.row(0).font_style = :bold + t.columns(1..5).align = :right + t.row(-1).font_style = :bold + t.row(1..-1).borders = [:bottom] + t.row(1..-1).border_color = 'DDDDDD' + end + end + + # As consolidações que compõem o valor — é o "clicar no motorista" da tela, + # em papel. + def tabela_composicao(cms) + linhas = [['Consolidação', 'Período', 'Situação', 'Pago em', 'Valor']] + cms.each do |cm| + c = cm.consolidacao + linhas << [ + c.nome, + "#{c.data_inicio.strftime('%d/%m/%y')}–#{c.data_fim.strftime('%d/%m/%y')}", + cm.pago? ? 'Pago' : 'A receber', + cm.pago? ? cm.pago_em.strftime('%d/%m/%Y') : '—', + moeda(cm.valor_total) + ] + end + linhas << ['TOTAL', '', '', '', moeda(cms.sum { |cm| cm.valor_total || 0 })] + + @pdf.table(linhas, header: true, width: @pdf.bounds.width, + column_widths: { 1 => 90, 2 => 70, 3 => 70, 4 => 80 }, + cell_style: { size: 8, padding: [4, 6] }) do |t| + t.row(0).background_color = PRETO + t.row(0).text_color = 'FFFFFF' + t.row(0).font_style = :bold + t.columns(4).align = :right + t.row(-1).font_style = :bold + t.row(1..-1).borders = [:bottom] + t.row(1..-1).border_color = 'DDDDDD' + end + end + end +end diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index 47b5202..e8eaf82 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -6,6 +6,8 @@
+ Consolidações <%= @incluir_rascunhos ? 'de qualquer status' : 'finalizadas' %> + com período dentro de <%= l @inicio, format: :short %> → <%= l @fim, format: :short %> +
+<%= icone :dinheiro %> Total do período
+<%= moeda(@totais.total_geral) %>
+<%= @totais.total_consolidacoes %> consolidação(ões) · <%= @totais.linhas.size %> motorista(s)
+<%= icone :sucesso, cor: 'text-green-400' %> Pago
+<%= moeda(@totais.total_pago) %>
+<%= icone :parcial, cor: 'text-amber-400' %> A pagar
+<%= moeda(@totais.total_pendente) %>
+<%= icone :ticket %> Média por motorista
++ <%= moeda(@totais.linhas.any? ? @totais.total_geral / @totais.linhas.size : 0) %> +
+<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>
+Nenhuma consolidação <%= @incluir_rascunhos ? '' : 'finalizada ' %>com período dentro dessa faixa.
+Amplie o período ou marque "Incluir rascunhos".
+| Motorista | +Consolidações | +Lançamentos | +Pago | +A pagar | +Total | +
|---|---|---|---|---|---|
| + <%= link_to totais_consolidacoes_path(inicio: @inicio, fim: @fim, + rascunhos: (@incluir_rascunhos ? '1' : nil), + motorista: (aberto ? nil : l[:motorista])), + class: 'text-white font-semibold hover:text-orange-500 flex items-center gap-1' do %> + <%= icone(aberto ? :expandir : :avancar, cor: 'text-gray-500', espaco: false) %><%= l[:motorista] %> + <% end %> + | +<%= l[:consolidacoes] %> | +<%= l[:lancamentos] %> | +<%= moeda(l[:valor_pago]) %> | +<%= moeda(l[:valor_pendente]) %> | +<%= moeda(l[:valor_total]) %> | +
| TOTAL | +<%= @totais.total_consolidacoes %> | ++ | <%= moeda(@totais.total_pago) %> | +<%= moeda(@totais.total_pendente) %> | +<%= moeda(@totais.total_geral) %> | +
+ <%= icone :dica %> Clique num motorista para ver as consolidações que compõem o total dele. +
+ <%# PDF sem a composição de cada motorista — para períodos com muita gente, + quando só o resumo interessa. %> + <%= link_to 'Baixar só o resumo (PDF)', + totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), detalhar: '0'), + data: { turbo: false }, + class: 'text-gray-400 hover:text-orange-500 text-xs underline whitespace-nowrap' %> ++ <%= @detalhe.size %> consolidação(ões) no período · + <%= moeda(@detalhe.sum { |cm| cm.valor_total || 0 }) %> +
+Nenhuma consolidação desse motorista neste período.
+ <% end %> + ++ <%= icone :calendario %> <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> + <% if cm.pago? %> + · <%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> + <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> + <% if cm.nota_fiscal.present? %>· NF <%= cm.nota_fiscal %><% end %> + <% end %> +
+<%= moeda(cm.valor_total) %>
+<%= icone :sucesso, cor: 'text-green-600', espaco: false %> Valor fechado para pagamento
<%= moeda(@valor_consolidado) %>
Soma dos pagamentos já confirmados pela empresa
-+ <%= icone :avancar, cor: 'text-orange-600' %> Ver por período e baixar o PDF +
+ <% end %>+ <%= icone :sucesso, cor: 'text-green-600', espaco: false %> Total fechado no período +
+<%= icone :calendario, cor: 'text-gray-500' %> <%= l @inicio, format: :short %> a <%= l @fim, format: :short %>
+<%= moeda(@totais.total_geral) %>
++ Soma de <%= @consolidacoes.size %> fechamento(s) da empresa neste período. +
+Cada fechamento abaixo entra na soma acima.
+ + <% if @consolidacoes.any? %> +<%= c.nome %>
+ <%= badge_pagamento(cm.pago? ? :pago : :pendente) %> ++ <%= icone :calendario %> <%= l c.data_inicio, format: :short %> a <%= l c.data_fim, format: :short %> +
+<%= moeda(cm.valor_total) %>
+ <% if cm.pago? %> ++ <%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> + <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> +
+ <% if cm.nota_fiscal.present? %> +<%= icone :nota_fiscal %> Nota Fiscal: <%= cm.nota_fiscal %>
+ <% end %> + <% else %> +<%= icone :parcial, cor: 'text-amber-400' %> Ainda não pago
+ <% end %> +<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>
+Nenhum fechamento neste período.
+Escolha outro período acima — o fechamento aparece aqui quando a empresa finaliza a consolidação.
+