teste #11

Merged
victor merged 7 commits from teste into main 2026-08-24 11:32:29 -03:00
8 changed files with 770 additions and 30 deletions
Showing only changes of commit cebd6e2dcd - Show all commits

View File

@@ -76,35 +76,43 @@ class DashboardController < ApplicationController
@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?
# 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.
# 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?
# 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
# 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
@entregas_falhadas = falhadas.count
@total_entregas = @entregas_pagas + @entregas_pendentes + @entregas_falhadas
@total_entregas = @entregas_atendidas + @entregas_pendentes
# Configurações de preço
config = Configuracao.mapa_de_precos
# Valor estimado total
@valor_estimado = (@entregas_pagas * config[:entrega]).round(2)
@valor_estimado = (@entregas_atendidas * config[:entrega]).round(2)
# Por motorista (todos)
@motoristas = pagas.group(:driver)
.count
.sort_by { |_, v| -v }
.map do |driver, qtd|
@motoristas = atendidas.group(:driver)
.count
.sort_by { |_, v| -v }
.map do |driver, qtd|
{
nome: driver,
entregas: qtd,
@@ -113,14 +121,14 @@ class DashboardController < ApplicationController
end
# Por local de entrega (contact_name)
@por_operacao = pagas.group(:contact_name)
.count
.sort_by { |_, v| -v }
.first(6)
.to_h
@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(pagas, config[:entrega])
@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).
@@ -221,20 +229,21 @@ class DashboardController < ApplicationController
.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)
# `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 = pagas.group("DATE(checkout)").count.transform_keys(&:to_s)
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 = pagas.group("TO_CHAR(checkout, 'YYYY-MM')").count
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) }

View File

@@ -121,12 +121,20 @@ class Entrega < ApplicationRecord
# Veículos usados pelo(s) motorista(s) selecionado(s) no período — alimenta o
# filtro dinâmico de veículos na criação de consolidação.
#
# ⚠️ Precisa usar EXATAMENTE o mesmo recorte da elegibilidade
# (`atendidas` + `no_periodo_checkout`, ver contar_atendidas e
# Consolidacao#veiculos_do_motorista). Usava `no_periodo` (planned_date), e o
# descasamento sumia com veículo do filtro: entrega planejada em 31/07 e
# entregue em 01/08 é elegível numa consolidação 01/0814/08 (checkout dentro),
# mas o veículo dela não aparecia (planned_date fora) — sem opção de marcar,
# a entrega ficava de fora do fechamento SEM aviso, e o financeiro não batia.
def self.veiculos_de_motoristas(motoristas, inicio: nil, fim: nil)
nomes = Array(motoristas).reject(&:blank?)
return [] if nomes.empty?
base = da_conta_gade.where(driver: nomes)
base = base.no_periodo(inicio, fim) if inicio.present? && fim.present?
base = base.atendidas.no_periodo_checkout(inicio, fim) if inicio.present? && fim.present?
base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?)
end

View File

@@ -134,8 +134,14 @@
<p class="text-3xl lg:text-4xl font-black text-white mb-1 break-words">
<%= moeda(@valor_estimado) %>
</p>
<%# "Atendidas" = concluídas + falhadas (motorista foi ao local e fechou
a visita). É a mesma base que a consolidação paga, por isso o rótulo
NÃO pode voltar a dizer "concluídas": o valor acima inclui as falhas. %>
<p class="text-orange-200 text-sm">
<%= @entregas_pagas %> entregas concluídas
<%= @entregas_atendidas %> entregas atendidas
</p>
<p class="text-orange-200/80 text-xs">
<%= @entregas_pagas %> concluídas · <%= @entregas_falhadas %> falhadas
</p>
</div>
</div>

252
bin/entregas_do_motorista Executable file
View File

@@ -0,0 +1,252 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Lista, DIRETO NA API do SimpliRoute, todas as visitas de um motorista dentro
# de um período.
#
# ⚠️ POR QUE É DIA A DIA (e não uma chamada só):
# A API NÃO tem filtro de intervalo. Testado contra a API real em 21/07/2026
# (bin/sondar_busca_nf): `planned_date_from/to`, `planned_date_after/before`,
# `__gte/__lte`, `since/until`, `start_date/end_date`, `date_from/to` — TODOS
# ignorados em silêncio (200 + o conjunto padrão inteiro). E SEM `planned_date`
# a API devolve um conjunto padrão (~2469 visitas) que NÃO cobre o histórico.
# Logo: `planned_date` é obrigatório e o período vira N requisições, uma por dia,
# disparadas em paralelo.
#
# ⚠️ Não filtramos motorista no servidor: `search=` só foi comprovado para NF, e
# parâmetro que a API não registra é IGNORADO em silêncio (devolve o dia inteiro
# com HTTP 200). Então baixamos o dia inteiro e filtramos AQUI — é lento
# (~3,8 MB / ~9 s por dia) mas é a única forma correta.
#
# SÓ LEITURA: o script faz apenas GET. Não altera nada.
#
# Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN):
#
# SIMPLIROUTE_TOKEN=xxx bin/entregas_do_motorista \
# --motorista "JOSE DA SILVA" --de 2026-08-01 --ate 2026-08-14
#
# ... --csv /tmp/entregas.csv grava o resultado em CSV
# ... --listar-motoristas só mostra quem aparece no período (sem filtrar)
# ... --campos mostra as chaves cruas de uma visita (diagnóstico)
#
# Alternativa MUITO mais barata quando não precisa do dado ao vivo: o espelho
# local (model Entrega, tabela db_reem_simplerout_2026) já tem a coluna `driver`
# e responde isso numa query só:
#
# Entrega.no_periodo('2026-08-01', '2026-08-14').do_motorista('JOSE DA SILVA')
#
# O espelho sincroniza depois da API, então só use este script quando precisar
# do estado ATUAL ou desconfiar da sync.
require 'net/http'
require 'json'
require 'uri'
require 'date'
require 'csv'
BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com')
TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s
THREADS = 6 # mesmo lote usado no sondar_busca_nf; acima disso a API estrangula
def sair(msg)
warn msg
exit 1
end
sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty?
motorista = nil
de = nil
ate = nil
csv_path = nil
listar = false
campos = false
ARGV.each_with_index do |a, i|
case a
when '--motorista' then motorista = ARGV[i + 1]
when '--de' then de = ARGV[i + 1]
when '--ate', '--até' then ate = ARGV[i + 1]
when '--csv' then csv_path = ARGV[i + 1]
when '--listar-motoristas' then listar = true
when '--campos' then campos = true
end
end
sair('Uso: bin/entregas_do_motorista --motorista "NOME" --de YYYY-MM-DD --ate YYYY-MM-DD') if de.to_s.empty? || ate.to_s.empty?
sair('Informe --motorista, ou use --listar-motoristas.') if motorista.to_s.empty? && !listar && !campos
inicio = begin
Date.parse(de)
rescue ArgumentError
sair("Data inválida em --de: #{de}")
end
fim = begin
Date.parse(ate)
rescue ArgumentError
sair("Data inválida em --ate: #{ate}")
end
sair('--de é depois de --ate.') if inicio > fim
DIAS = (inicio..fim).to_a
# Normaliza nome para comparar: sem acento, sem espaço duplo, maiúsculo.
# O nome no SimpliRoute é digitado por gente — "José da Silva" e "JOSE DA SILVA"
# são o mesmo motorista.
def normalizar(nome)
nome.to_s
.unicode_normalize(:nfd).gsub(/\p{Mn}/, '')
.upcase.gsub(/\s+/, ' ').strip
end
def requisicao(caminho)
uri = URI.join(BASE, caminho)
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Token #{TOKEN}"
req['Accept'] = 'application/json'
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
open_timeout: 10, read_timeout: 180) { |h| h.request(req) }
return [res.code.to_i, nil] unless res.code.to_i == 200
[200, (res.body.to_s.empty? ? nil : JSON.parse(res.body))]
rescue StandardError => e
[0, "ERRO: #{e.class}: #{e.message}"]
end
# A resposta pode ser lista crua OU paginada ({count, next, results}).
def itens(corpo)
return corpo if corpo.is_a?(Array)
return Array(corpo['results']) if corpo.is_a?(Hash) && corpo.key?('results')
[]
end
# Onde está o nome do motorista numa visita. A lista-por-data às vezes traz o
# campo achatado ('driver'), às vezes só o id, às vezes um hash aninhado — por
# isso procuramos em várias formas em vez de fixar uma. Se nada casar, o script
# avisa e o --campos mostra o formato real.
CHAVES_MOTORISTA = %w[driver driver_name route_driver driver_full_name].freeze
def nome_do_motorista(visita)
CHAVES_MOTORISTA.each do |k|
v = visita[k]
next if v.nil? || v == '' || v.is_a?(Numeric)
return v if v.is_a?(String)
return (v['name'] || v['full_name'] ||
[v['first_name'], v['last_name']].compact.join(' ')) if v.is_a?(Hash)
end
nil
end
puts "Base: #{BASE}"
puts "Período: #{inicio.strftime('%d/%m/%Y')} a #{fim.strftime('%d/%m/%Y')} (#{DIAS.size} dia(s), #{THREADS} em paralelo)"
puts "Motorista: #{motorista}" if motorista
puts '(a API não filtra por intervalo nem por motorista — baixando dia a dia; pode levar ~1 min)'
puts
# ── Varredura dia a dia, em lotes ────────────────────────────────────────────
t0 = Time.now
visitas = []
falhas = []
mutex = Mutex.new
DIAS.each_slice(THREADS) do |lote|
lote.map do |dia|
Thread.new do
cod, corpo = requisicao("/v1/routes/visits/?planned_date=#{dia.iso8601}")
lista = cod == 200 ? itens(corpo) : []
mutex.synchronize do
if cod == 200
# planned_date às vezes não volta na visita — garantimos o dia pedido.
lista.each { |v| v['_dia'] = dia.iso8601 }
visitas.concat(lista)
print '.'
else
falhas << [dia, cod, corpo]
print 'x'
end
end
end
end.each(&:join)
end
puts "\nBaixadas #{visitas.size} visita(s) em #{(Time.now - t0).round(1)}s"
falhas.each { |d, c, e| warn " ⚠️ #{d}: HTTP #{c} #{e.inspect[0, 120]}" }
sair('Nenhuma visita voltou — período sem operação ou token sem acesso.') if visitas.empty?
# ── Diagnóstico do formato ───────────────────────────────────────────────────
if campos
puts "\nChaves de uma visita crua:"
puts visitas.first.keys.sort.inspect
puts "\nExemplo (recortado):"
puts JSON.pretty_generate(visitas.first)[0, 2000]
exit 0
end
com_nome = visitas.count { |v| nome_do_motorista(v) }
if com_nome.zero?
warn "\n⚠ NENHUMA visita trouxe nome de motorista nas chaves #{CHAVES_MOTORISTA.inspect}."
warn ' A lista-por-data pode estar devolvendo só o id da rota. Rode com --campos'
warn ' para ver o formato real, ou use o espelho local (Entrega#driver).'
exit 1
end
warn "\n⚠ #{visitas.size - com_nome} visita(s) sem motorista preenchido (ficam de fora do filtro)." if com_nome < visitas.size
# ── Só listar quem apareceu ──────────────────────────────────────────────────
if listar
puts "\nMotoristas no período:"
visitas.group_by { |v| nome_do_motorista(v) }
.reject { |k, _| k.nil? }
.sort_by { |_, vs| -vs.size }
.each { |nome, vs| puts " #{vs.size.to_s.rjust(5)} #{nome}" }
exit 0
end
# ── Filtro pelo motorista ────────────────────────────────────────────────────
alvo = normalizar(motorista)
dele = visitas.select { |v| normalizar(nome_do_motorista(v)) == alvo }
if dele.empty?
puts "\nNenhuma visita de #{motorista.inspect} no período."
parecidos = visitas.map { |v| nome_do_motorista(v) }.compact.uniq
.select { |n| normalizar(n).include?(alvo) || alvo.include?(normalizar(n)) }
puts "Nomes parecidos que existem: #{parecidos.inspect}" if parecidos.any?
puts 'Use --listar-motoristas para ver a grafia exata.'
exit 0
end
dele.sort_by! { |v| [v['_dia'].to_s, v['checkout_time'].to_s] }
puts "\n#{'─' * 100}"
puts "#{dele.size} visita(s) de #{motorista}"
puts '─' * 100
printf("%-11s %-10s %-9s %-19s %-40s\n", 'DATA', 'NF', 'STATUS', 'CHECKOUT', 'DESTINO')
dele.each do |v|
printf("%-11s %-10s %-9s %-19s %-40s\n",
Date.parse(v['_dia']).strftime('%d/%m/%Y'),
v['reference'].to_s[0, 10],
v['status'].to_s[0, 9],
v['checkout_time'].to_s[0, 19].tr('T', ' '),
v['title'].to_s[0, 40])
end
puts '─' * 100
por_status = dele.group_by { |v| v['status'].to_s }.transform_values(&:size)
puts "Total: #{dele.size} " + por_status.sort_by { |_, n| -n }.map { |s, n| "#{s}=#{n}" }.join(' ')
atendidas = dele.count { |v| %w[completed failed].include?(v['status'].to_s) && v['checkout_time'].to_s != '' }
puts "Atendidas (foi ao local, com checkout): #{atendidas}"
puts "Dias com entrega: #{dele.map { |v| v['_dia'] }.uniq.size}"
# ── CSV ──────────────────────────────────────────────────────────────────────
if csv_path
CSV.open(csv_path, 'w') do |csv|
csv << ['Data', 'NF', 'Tracking', 'ID visita', 'Motorista', 'Status',
'Checkout', 'Título', 'Endereço', 'Motivo (UUID)', 'Comentário']
dele.each do |v|
csv << [Date.parse(v['_dia']).strftime('%d/%m/%Y'), v['reference'], v['tracking_id'], v['id'],
nome_do_motorista(v), v['status'], v['checkout_time'], v['title'], v['address'],
v['checkout_observation'], v['checkout_comment']]
end
end
puts "\nCSV gravado em #{csv_path}"
end

View File

@@ -0,0 +1,118 @@
# lib/tasks/entregas_motorista.rake
#
# Lista as entregas de um motorista num período lendo o ESPELHO LOCAL
# (model Entrega / tabela db_reem_simplerout_2026) — sem tocar na API do
# SimpliRoute, logo sem precisar de SIMPLIROUTE_TOKEN.
#
# Por que aqui e não na API: a API não tem filtro de intervalo nem de motorista
# (ver bin/entregas_do_motorista), então um período vira N requisições de vários
# MB. No espelho é uma query só. O preço é a defasagem da sync — se precisar do
# estado AO VIVO, use bin/entregas_do_motorista.
#
# SÓ LEITURA: Entrega#readonly? é true; nada aqui escreve.
#
# rake "entregas:motorista[JOSE DA SILVA,2026-08-01,2026-08-14]"
# rake "entregas:motorista[JOSE DA SILVA,2026-08-01,2026-08-14,/tmp/saida.csv]"
# rake "entregas:motoristas[2026-08-01,2026-08-14]" # quem operou no período
#
# ⚠️ O nome tem que ser a grafia EXATA da coluna `driver` (do_motorista usa
# igualdade). Rode `entregas:motoristas` primeiro para ver a lista; se o nome
# passado não bater exatamente, a task sugere os parecidos em vez de devolver
# vazio silenciosamente.
#
# ⚠️ Período pelo `planned_date` (dia planejado da visita), que é o mesmo eixo
# do dashboard. As análises FINANCEIRAS usam `checkout` (data real da entrega) —
# são recortes diferentes; passe FONTE=checkout para usar esse outro eixo.
namespace :entregas do
desc 'Entregas de um motorista no período, lidas do espelho local (sem API)'
task :motorista, %i[nome inicio fim csv] => :environment do |_t, args|
nome = args[:nome].to_s.strip
inicio = args[:inicio].to_s.strip
fim = args[:fim].to_s.strip
if nome.empty? || inicio.empty? || fim.empty?
abort 'Uso: rake "entregas:motorista[NOME,YYYY-MM-DD,YYYY-MM-DD,/caminho/opcional.csv]"'
end
d1 = Date.parse(inicio)
d2 = Date.parse(fim)
abort 'Início é depois do fim.' if d1 > d2
# planned_date = dia planejado; checkout = dia em que a entrega ocorreu.
por_checkout = ENV['FONTE'].to_s.casecmp('checkout').zero?
base = Entrega.da_conta_gade
base = por_checkout ? base.no_periodo_checkout(d1, d2) : base.no_periodo(d1, d2)
entregas = base.do_motorista(nome).order(:planned_date, :checkout).to_a
if entregas.empty?
puts "Nenhuma entrega de #{nome.inspect} entre #{d1.strftime('%d/%m/%Y')} e #{d2.strftime('%d/%m/%Y')}."
# Nome quase certo é o erro mais comum aqui — mostrar o vizinho evita
# concluir "o motorista não rodou" quando na verdade a grafia divergiu.
alvo = nome.upcase
parecidos = Entrega.motoristas_ativos(inicio: d1, fim: d2)
.select { |n| n.upcase.include?(alvo) || alvo.include?(n.upcase) }
puts "Nomes parecidos no período: #{parecidos.inspect}" if parecidos.any?
puts 'Rode: rake "entregas:motoristas[' + "#{d1},#{d2}" + ']" para ver a grafia exata.'
next
end
eixo = por_checkout ? 'checkout (data real)' : 'planned_date (dia planejado)'
puts "#{'─' * 104}"
puts "#{entregas.size} entrega(s) de #{nome} · #{d1.strftime('%d/%m/%Y')} a #{d2.strftime('%d/%m/%Y')} · eixo: #{eixo}"
puts '─' * 104
printf("%-11s %-10s %-10s %-10s %-17s %-38s\n", 'DATA', 'NF', 'VEÍCULO', 'STATUS', 'CHECKOUT', 'DESTINO')
entregas.each do |e|
printf("%-11s %-10s %-10s %-10s %-17s %-38s\n",
e.planned_date&.strftime('%d/%m/%Y').to_s,
e.reference_id.to_s[0, 10],
e.vehicle.to_s[0, 10],
e.status.to_s[0, 10],
e.checkout&.strftime('%d/%m %H:%M').to_s,
e.local.to_s[0, 38])
end
puts '─' * 104
por_status = entregas.group_by { |e| e.status.to_s }.transform_values(&:size)
puts "Total: #{entregas.size} " + por_status.sort_by { |_, n| -n }.map { |s, n| "#{s}=#{n}" }.join(' ')
# "Atendidas" = foi ao local (sucesso OU insucesso) com checkout — é o
# conjunto elegível da consolidação, e não o mesmo que "entregues".
puts "Entregues (completed + checkout) ..... #{entregas.count { |e| e.concluida? && e.checkout_registrado? }}"
puts "Insucessos (failed) .................. #{entregas.count(&:falhada?)}"
puts "Atendidas (foi ao local c/ checkout) . #{entregas.count { |e| Entrega::STATUS_ATENDIDO.include?(e.status) && e.checkout_registrado? }}"
puts "Dias com entrega ..................... #{entregas.map { |e| e.planned_date&.to_date }.compact.uniq.size}"
puts "Veículos ............................. #{entregas.map(&:vehicle).compact.reject(&:empty?).uniq.join(', ')}"
if args[:csv].present?
require 'csv'
CSV.open(args[:csv], 'w') do |csv|
csv << ['Data planejada', 'NF', 'Tracking', 'Motorista', 'Veículo', 'Status', 'Checkout', 'Local', 'Endereço', 'Motivo']
entregas.each do |e|
csv << [e.planned_date, e.reference_id, e.tracking_id, e.driver, e.vehicle,
e.status, e.checkout, e.local, e.try(:address), e.try(:observation)]
end
end
puts "\nCSV gravado em #{args[:csv]}"
end
end
desc 'Motoristas que aparecem no espelho dentro do período (grafia exata + volume)'
task :motoristas, %i[inicio fim] => :environment do |_t, args|
abort 'Uso: rake "entregas:motoristas[YYYY-MM-DD,YYYY-MM-DD]"' if args[:inicio].blank? || args[:fim].blank?
d1 = Date.parse(args[:inicio])
d2 = Date.parse(args[:fim])
contagem = Entrega.da_conta_gade.no_periodo(d1, d2)
.where.not(driver: [nil, '']).group(:driver).count
if contagem.empty?
puts "Nenhuma entrega no espelho entre #{d1.strftime('%d/%m/%Y')} e #{d2.strftime('%d/%m/%Y')}."
next
end
puts "Motoristas · #{d1.strftime('%d/%m/%Y')} a #{d2.strftime('%d/%m/%Y')} (#{contagem.values.sum} entregas)"
contagem.sort_by { |_, n| -n }.each { |nome, n| puts " #{n.to_s.rjust(5)} #{nome}" }
end
end

137
spec/models/entrega_spec.rb Normal file
View File

@@ -0,0 +1,137 @@
require 'rails_helper'
# Trava o EIXO DE DATA do espelho de rastreio.
#
# O sistema tem dois eixos e eles NÃO são intercambiáveis:
# planned_date → dia planejado da visita (scope :no_periodo)
# checkout → dia REAL da entrega (scope :no_periodo_checkout)
#
# A elegibilidade da consolidação — o que vira dinheiro — é sempre pelo
# `checkout`. Um filtro que use `planned_date` some com entrega elegível na
# virada de mês e o fechamento não bate. Foi exatamente o bug do seletor de
# veículos da nova consolidação (Entrega.veiculos_de_motoristas usava
# :no_periodo), por isso o caso da virada está fixado aqui.
#
# A tabela do espelho é EXTERNA (sem migration, sem factory) — quem a cria e
# derruba no banco de teste é o harness em spec/support/espelho_rastreio.rb, que
# também expõe o helper `semear`.
RSpec.describe Entrega, type: :model do
include_context 'espelho de rastreio'
describe '.veiculos_de_motoristas' do
# Período de fechamento usado em todos os exemplos.
let(:inicio) { Date.new(2026, 8, 1) }
let(:fim) { Date.new(2026, 8, 14) }
it 'inclui o veículo de entrega PLANEJADA fora do período mas ENTREGUE dentro' do
# A regressão: planejada em 31/07, entregue em 01/08. É elegível (checkout
# dentro), logo o veículo TEM que aparecer — senão não há como marcá-lo no
# filtro e a entrega sai do fechamento sem aviso.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_001', status: 'completed',
planned_date: Time.zone.local(2026, 7, 31, 8, 0),
checkout: Time.zone.local(2026, 8, 1, 9, 30) }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to eq(['GADE_001'])
end
it 'exclui o veículo de entrega PLANEJADA dentro do período mas ENTREGUE fora' do
# O espelho do caso acima: planejada em 14/08, entregue em 20/08. Não é
# elegível neste fechamento, então o veículo não pode aparecer.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_002', status: 'completed',
planned_date: Time.zone.local(2026, 8, 14, 8, 0),
checkout: Time.zone.local(2026, 8, 20, 9, 30) }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to be_empty
end
it 'inclui o último dia do período inteiro (checkout às 23h de 14/08)' do
# no_periodo_checkout usa `< fim + 1`; um `<= fim` cortaria o último dia.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_003', status: 'completed',
planned_date: Time.zone.local(2026, 8, 14, 7, 0),
checkout: Time.zone.local(2026, 8, 14, 23, 40) }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to eq(['GADE_003'])
end
it 'inclui veículo de INSUCESSO (failed conta como atendida)' do
# Atendida = o motorista foi ao local, com sucesso OU insucesso. Insucesso
# é elegível e precisa ser classificado no wizard.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_004', status: 'failed',
planned_date: Time.zone.local(2026, 8, 5, 8, 0),
checkout: Time.zone.local(2026, 8, 5, 16, 0) }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to eq(['GADE_004'])
end
it 'exclui veículo que só tem entrega PENDENTE (sem checkout) no período' do
# Pendente não é elegível; listar o veículo só sujaria o filtro com um
# carro de zero elegíveis.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_005', status: 'pending',
planned_date: Time.zone.local(2026, 8, 6, 8, 0), checkout: nil }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to be_empty
end
it 'não vaza veículo de outro motorista' do
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_006', status: 'completed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) },
{ driver: 'MARIA SOUZA', vehicle: 'GADE_007', status: 'completed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 11, 0) }
])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to eq(['GADE_006'])
end
# A invariante que realmente protege o financeiro: o seletor de veículos do
# formulário e o conjunto elegível da consolidação têm que enxergar o MESMO
# recorte. Se alguém mexer no eixo de data de um dos dois, este exemplo cai.
it 'lista exatamente os veículos que têm entrega elegível no período' do
semear([
# elegíveis (checkout dentro)
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_010', status: 'completed',
planned_date: Time.zone.local(2026, 7, 30, 8, 0), checkout: Time.zone.local(2026, 8, 2, 10, 0) },
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_011', status: 'failed',
planned_date: Time.zone.local(2026, 8, 9, 8, 0), checkout: Time.zone.local(2026, 8, 9, 10, 0) },
# não elegíveis
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_012', status: 'completed',
planned_date: Time.zone.local(2026, 8, 10, 8, 0), checkout: Time.zone.local(2026, 9, 1, 10, 0) },
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_013', status: 'pending',
planned_date: Time.zone.local(2026, 8, 11, 8, 0), checkout: nil }
])
elegiveis = described_class.atendidas.da_conta_gade
.no_periodo_checkout(inicio, fim)
.do_motorista('JOSE DA SILVA')
.distinct.pluck(:vehicle).compact.sort
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim).sort)
.to eq(elegiveis)
expect(elegiveis).to eq(%w[GADE_010 GADE_011])
end
it 'devolve vazio sem motorista informado (não toca o banco)' do
expect(described_class.veiculos_de_motoristas([], inicio: inicio, fim: fim)).to eq([])
expect(described_class.veiculos_de_motoristas(nil, inicio: inicio, fim: fim)).to eq([])
end
end
# Os dois scopes são a origem da confusão — vale fixar que cada um olha para a
# coluna que promete, para nenhum refactor trocar um pelo outro em silêncio.
describe 'eixos de período' do
it ':no_periodo filtra por planned_date e :no_periodo_checkout por checkout' do
expect(described_class.no_periodo(Date.new(2026, 8, 1), Date.new(2026, 8, 14)).to_sql)
.to include('planned_date')
sql_checkout = described_class.no_periodo_checkout(Date.new(2026, 8, 1), Date.new(2026, 8, 14)).to_sql
expect(sql_checkout).to include('checkout')
expect(sql_checkout).not_to include('planned_date')
end
end
end

View File

@@ -12,3 +12,111 @@ RSpec.describe 'Dashboard', type: :request do
expect(response).to redirect_to(motorista_dashboard_path)
end
end
# Trava a CONTABILIZAÇÃO DAS FALHAS no dashboard principal.
#
# A base financeira do dashboard é `atendidas` (concluídas + falhadas, com
# checkout) — o MESMO conjunto que a consolidação considera elegível e paga.
# Antes usava só `pagas` (concluídas) e o valor da tela ficava menor que o do
# fechamento; e as falhas eram filtradas por `planned_date`, caindo no período
# errado. Os dois casos estão fixados aqui.
#
# ⚠️ Falhada TEM checkout: o motorista foi ao local e fechou a visita com motivo
# de insucesso. É o que torna `checkout` o eixo correto também para elas.
RSpec.describe 'Dashboard — contabilização de falhas', type: :request do
include_context 'espelho de rastreio'
# Preço redondo para os valores esperados serem óbvios na leitura do spec.
let(:preco) { 10.0 }
let(:inicio) { '2026-08-01' }
let(:fim) { '2026-08-14' }
before do
create(:configuracao, chave: 'preco_entrega', valor: preco.to_s)
sign_in create(:gerente)
end
def abrir_dashboard
get dashboard_path, params: { inicio: inicio, fim: fim }
expect(response).to have_http_status(:ok)
response.body
end
it 'soma a falhada ao valor estimado e ao total de atendidas' do
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_001', status: 'completed',
planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) },
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_001', status: 'failed',
planned_date: Time.zone.local(2026, 8, 6, 8, 0), checkout: Time.zone.local(2026, 8, 6, 11, 0) }
])
corpo = abrir_dashboard
# 2 atendidas × R$ 10,00 — com a base antiga (`pagas`) seria 1 e R$ 10,00.
expect(corpo).to include('2 entregas atendidas')
expect(corpo).to include('1 concluídas · 1 falhadas')
expect(corpo).to include('R$ 20,00')
end
it 'conta a falhada pela data do CHECKOUT, não pela planejada' do
# Planejada em 31/07 (fora), entregue em 01/08 (dentro) → entra.
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_002', status: 'failed',
planned_date: Time.zone.local(2026, 7, 31, 8, 0), checkout: Time.zone.local(2026, 8, 1, 9, 0) }
])
corpo = abrir_dashboard
expect(corpo).to include('1 entregas atendidas')
expect(corpo).to include('0 concluídas · 1 falhadas')
end
it 'exclui a falhada planejada dentro do período mas com checkout fora' do
# O espelho do caso acima: planejada em 10/08 (dentro), entregue em 01/09
# (fora) → não entra. Com o eixo antigo (planned_date) entrava.
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_003', status: 'failed',
planned_date: Time.zone.local(2026, 8, 10, 8, 0), checkout: Time.zone.local(2026, 9, 1, 9, 0) }
])
corpo = abrir_dashboard
expect(corpo).to include('0 entregas atendidas')
expect(corpo).to include('0 concluídas · 0 falhadas')
end
it 'mantém a pendente fora do valor, contando-a só como pendente' do
# Pendente não tem checkout: continua no eixo planned_date e não vira R$.
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_004', status: 'pending',
planned_date: Time.zone.local(2026, 8, 7, 8, 0), checkout: nil }
])
corpo = abrir_dashboard
expect(corpo).to include('0 entregas atendidas')
expect(corpo).to include('1 pendentes')
end
it 'inclui a falhada no ranking por motorista' do
semear([
# JOSE: 1 concluída + 1 falhada = 2 atendidas → R$ 20,00
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_005', status: 'completed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) },
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_005', status: 'failed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 15, 0) },
# MARIA: 1 concluída = 1 atendida → R$ 10,00
{ driver: 'MARIA SOUZA', vehicle: 'GADE_006', status: 'completed',
planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) }
])
corpo = abrir_dashboard
expect(corpo).to include('3 entregas atendidas')
expect(corpo).to include('JOSE DA SILVA')
# R$ 20,00 do JOSE só existe se a falhada dele entrou na conta do ranking
# (com a base antiga ele apareceria com R$ 10,00, igual à MARIA).
expect(corpo).to include('R$ 20,00')
expect(corpo).to include('R$ 30,00') # total estimado do período
end
end

View File

@@ -0,0 +1,102 @@
# Harness da tabela do espelho de rastreio (db_reem_simplerout_2026) para os
# specs.
#
# A tabela é EXTERNA: não tem migration (o model Entrega proíbe explicitamente)
# e não tem factory. Sem este harness, qualquer teste que dependa dela só
# poderia mockar o método sob teste — o que não pega regressão de SQL, que é
# justamente onde moram os bugs de eixo de data (planned_date × checkout).
#
# Uso:
#
# RSpec.describe Entrega do
# include_context 'espelho de rastreio'
#
# it '...' do
# semear([{ driver: 'FULANO', vehicle: 'GADE_001', status: 'completed',
# planned_date: ..., checkout: ... }])
# end
# end
module EspelhoRastreio
TABELA = 'db_reem_simplerout_2026'.freeze
# `Entrega.da_conta_gade` filtra por DB_EXISTING_ACCOUNT_ID (default '95907').
# Gravamos nas linhas o primeiro valor configurado: se estiver 'all' o scope
# não filtra e o valor é indiferente; se estiver '95907' as linhas casam.
# Assim o harness não depende do ENV da máquina nem precisa stubar ENV.
CONTA = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').split(',').first.to_s.strip.presence || '95907'
class << self
def criar!
# ⚠️ GUARDA OBRIGATÓRIA. O model do espelho proíbe DROP porque a tabela
# real é a base de rastreio da operação. Aqui só criamos uma tabela de
# mesmo nome no banco de TESTE; se ela já existir COM DADOS, estamos
# apontados para dados de verdade e o spec tem que abortar em vez de
# destruí-los.
raise "Espelho de teste exige RAILS_ENV=test (está em #{Rails.env})" unless Rails.env.test?
return if @criada
if conexao.table_exists?(TABELA)
linhas = conexao.select_value("SELECT COUNT(*) FROM #{nome_citado}").to_i
if linhas.positive?
raise "#{TABELA} já existe no banco de teste com #{linhas} linha(s) — " \
'o spec NÃO vai derrubá-la. Aponte RAILS_ENV=test para um banco descartável.'
end
conexao.drop_table(TABELA)
end
conexao.create_table(TABELA, id: false) do |t|
t.string :tracking_id, null: false
t.string :account_id
t.bigint :reference_id
t.string :driver
t.string :vehicle
t.string :status
t.datetime :planned_date
t.datetime :checkout
t.string :contact_name
t.string :address
end
conexao.execute("ALTER TABLE #{nome_citado} ADD PRIMARY KEY (tracking_id)")
@criada = true
end
# Só derruba o que este harness criou.
def derrubar!
return unless @criada
conexao.drop_table(TABELA, if_exists: true)
@criada = false
end
def limpar!
conexao.execute("DELETE FROM #{nome_citado}") if @criada
end
private
def conexao
ActiveRecord::Base.connection
end
def nome_citado
conexao.quote_table_name(TABELA)
end
end
end
RSpec.shared_context 'espelho de rastreio' do
before(:all) { EspelhoRastreio.criar! }
after(:all) { EspelhoRastreio.derrubar! }
before { EspelhoRastreio.limpar! }
# O model é readonly? = true, então insert_all (que não instancia registro) é
# a única via de gravação — e vale só para o banco de teste.
def semear(linhas)
Entrega.insert_all(
linhas.map.with_index do |l, i|
{ tracking_id: "TRK-#{i}-#{SecureRandom.hex(3)}", account_id: EspelhoRastreio::CONTA }.merge(l)
end
)
end
end