diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index e1f349c..d530fe9 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -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) } diff --git a/app/models/entrega.rb b/app/models/entrega.rb index fe76fa2..3882316 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -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/08–14/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 diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 167d030..84dc3cd 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -134,8 +134,14 @@
<%= moeda(@valor_estimado) %>
+ <%# "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. %>- <%= @entregas_pagas %> entregas concluídas + <%= @entregas_atendidas %> entregas atendidas +
++ <%= @entregas_pagas %> concluídas · <%= @entregas_falhadas %> falhadas
diff --git a/bin/entregas_do_motorista b/bin/entregas_do_motorista new file mode 100755 index 0000000..5e79b1b --- /dev/null +++ b/bin/entregas_do_motorista @@ -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 diff --git a/lib/tasks/entregas_motorista.rake b/lib/tasks/entregas_motorista.rake new file mode 100644 index 0000000..593620c --- /dev/null +++ b/lib/tasks/entregas_motorista.rake @@ -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 diff --git a/spec/models/entrega_spec.rb b/spec/models/entrega_spec.rb new file mode 100644 index 0000000..ed509ee --- /dev/null +++ b/spec/models/entrega_spec.rb @@ -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 diff --git a/spec/requests/dashboard_spec.rb b/spec/requests/dashboard_spec.rb index cc4518a..7ddb2ab 100644 --- a/spec/requests/dashboard_spec.rb +++ b/spec/requests/dashboard_spec.rb @@ -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 diff --git a/spec/support/espelho_rastreio.rb b/spec/support/espelho_rastreio.rb new file mode 100644 index 0000000..08d112d --- /dev/null +++ b/spec/support/espelho_rastreio.rb @@ -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