, Correçoes UX consolidaçoes e de backa-end

This commit is contained in:
2026-08-28 16:38:51 -03:00
parent b6fe6f796f
commit bfa474262f
7 changed files with 290 additions and 40 deletions

View File

@@ -284,6 +284,37 @@ class ConsolidacoesController < ApplicationController
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
end
# POST /consolidacoes/arquivar_em_lote — arquiva as consolidações marcadas na
# lista. Mesma regra do arquivar de uma só (nenhuma trava extra): arquivar é
# REVERSÍVEL (Arquivadas → Reativar), então o lote não precisa de confirmação
# mais dura do que a de uma — o que ele precisa é dizer QUANTAS foram.
#
# Ignora em silêncio id que não está mais ativo (alguém arquivou pela outra
# aba enquanto a lista estava aberta): o resultado que o operador queria — "essas
# saem da lista" — já é o que está na tela, e erro aqui só assustaria à toa.
def arquivar_em_lote
authorize Consolidacao, :arquivar?
ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
if ids.empty?
return redirect_back fallback_location: consolidacoes_path,
alert: 'Marque ao menos uma consolidação para arquivar.'
end
alvos = Consolidacao.ativas.where(id: ids).to_a
alvos.each do |c|
c.arquivar!(current_user)
auditar!(:arquivar, c, dados_novos: { em_lote: true, total_do_lote: alvos.size })
end
aviso = case alvos.size
when 0 then 'Nenhuma das consolidações marcadas continuava ativa.'
when 1 then "1 consolidação arquivada. Você pode restaurar em \"Arquivadas\"."
else "#{alvos.size} consolidações arquivadas. Você pode restaurar em \"Arquivadas\"."
end
redirect_back fallback_location: consolidacoes_path, notice: aviso
end
# GET /consolidacoes/arquivadas — histórico de consolidações arquivadas
def arquivadas
authorize Consolidacao

View File

@@ -2,6 +2,26 @@
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?
@@ -247,11 +267,26 @@ class DashboardController < ApplicationController
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)
# ⚠️ 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 = ces.distinct.count(:tracking_id)
@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)
@@ -266,12 +301,21 @@ class DashboardController < ApplicationController
end
@fin_por_operacao = por_op.sort_by { |_, v| -v }
# Composição por tipo (normal/retirada/bônus/desconto)
# 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_por_tipo = ConsolidacaoEntrega::TIPO_CORES.filter_map do |tipo, cfg|
@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: cfg[:label], valor: valor }
{ label: ConsolidacaoEntrega::TIPO_CORES.dig(tipo, :label) || tipo, valor: valor, cor: cor }
end
# Custo por motorista (todos)
@@ -288,13 +332,37 @@ class DashboardController < ApplicationController
# 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)
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.