Pagamento por checkout + consolidação por veículo (em vez de rota)

Regra de pagamento:
- Entrega.pagas agora exige status='completed' E checkout preenchido
  (antes era checkin). Afeta dashboard, painel do motorista, job 1h e
  elegibilidade das consolidações de forma consistente.

Veículo no lugar de rota na consolidação:
- migration troca consolidacoes.route_ids por vehicle_ids (jsonb + gin)
- Entrega: da_veiculo/veiculos_unicos; contar_pagas usa vehicle
- new.html.erb: filtro "Veículos" (multi-select por vehicle)
- validar.html.erb: mostra o veículo em vez do código da rota
- filtros/elegibilidade do controller passam a usar vehicle_ids

Docs/diagnóstico atualizados.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 02:17:25 -03:00
parent 13b0161dd5
commit 19b7cc838f
11 changed files with 58 additions and 35 deletions

View File

@@ -54,7 +54,7 @@ Default credentials (change in production): `admin@gade.com` / `Gade@2026!`
The table `public.db_reem_simplerout_2026` is owned by an external system (SimpleRoute) that updates it every hour. **NEVER** create migrations for it, and never run INSERT/UPDATE/DELETE/DROP/TRUNCATE on it. The table `public.db_reem_simplerout_2026` is owned by an external system (SimpleRoute) that updates it every hour. **NEVER** create migrations for it, and never run INSERT/UPDATE/DELETE/DROP/TRUNCATE on it.
`Entrega` (app/models/entrega.rb) maps to this table and enforces `readonly? = true`. All queries go through its scopes — the most important being `.pagas` (`status='completed' AND checkin IS NOT NULL`), which defines payment eligibility. `Entrega` (app/models/entrega.rb) maps to this table and enforces `readonly? = true`. All queries go through its scopes — the most important being `.pagas` (`status='completed' AND checkout IS NOT NULL`), which defines payment eligibility. Consolidations filter eligible deliveries by **vehicle** (`vehicle` column → `Consolidacao#vehicle_ids`), not by route.
## Architecture ## Architecture

View File

@@ -16,7 +16,7 @@ class ConsolidacaoEntregasController < ApplicationController
.da_conta_gade .da_conta_gade
.no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim) .no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim)
.do_motorista(@motorista) .do_motorista(@motorista)
@entregas = @entregas.da_rota(@consolidacao.route_ids) if @consolidacao.route_ids.present? @entregas = @entregas.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
@entregas = @entregas.order(:planned_date) @entregas = @entregas.order(:planned_date)
# Classificações já feitas — indexadas por tracking_id # Classificações já feitas — indexadas por tracking_id
@@ -139,18 +139,18 @@ class ConsolidacaoEntregasController < ApplicationController
nome.present? && @consolidacao.consolidacao_motoristas.exists?(motorista_nome: nome) nome.present? && @consolidacao.consolidacao_motoristas.exists?(motorista_nome: nome)
end end
# tracking_ids pagos/elegíveis para o motorista no período (respeita rotas) # tracking_ids pagos/elegíveis para o motorista no período (respeita veículos)
def tracking_ids_elegiveis(motorista) def tracking_ids_elegiveis(motorista)
return [] unless motorista.present? return [] unless motorista.present?
rel = Entrega.pagas.da_conta_gade rel = Entrega.pagas.da_conta_gade
.no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim) .no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim)
.do_motorista(motorista) .do_motorista(motorista)
rel = rel.da_rota(@consolidacao.route_ids) if @consolidacao.route_ids.present? rel = rel.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
rel.pluck(:tracking_id) rel.pluck(:tracking_id)
end end
# Valida que a entrega pertence ao motorista informado e ao período/rota da # Valida que a entrega pertence ao motorista informado e ao período/veículo da
# consolidação — impede classificar tracking_ids arbitrários via requisição. # consolidação — impede classificar tracking_ids arbitrários via requisição.
def entrega_elegivel?(motorista, tracking_id) def entrega_elegivel?(motorista, tracking_id)
motorista_na_consolidacao?(motorista) && motorista_na_consolidacao?(motorista) &&

View File

@@ -22,8 +22,8 @@ class ConsolidacoesController < ApplicationController
@consolidacoes = @consolidacoes.where(id: ids) @consolidacoes = @consolidacoes.where(id: ids)
end end
if params[:route_id].present? if params[:vehicle].present?
@consolidacoes = @consolidacoes.where('route_ids @> ?', [params[:route_id]].to_json) @consolidacoes = @consolidacoes.where('vehicle_ids @> ?', [params[:vehicle]].to_json)
end end
end end
@@ -35,7 +35,7 @@ class ConsolidacoesController < ApplicationController
data_fim: Date.current data_fim: Date.current
) )
@motoristas = Entrega.motoristas_ativos @motoristas = Entrega.motoristas_ativos
@rotas = Entrega.rotas_unicas @veiculos = Entrega.veiculos_unicos
end end
# POST /consolidacoes # POST /consolidacoes
@@ -160,10 +160,10 @@ class ConsolidacoesController < ApplicationController
def preparar_form_new def preparar_form_new
@motoristas = Entrega.motoristas_ativos @motoristas = Entrega.motoristas_ativos
@rotas = Entrega.rotas_unicas @veiculos = Entrega.veiculos_unicos
end end
def consolidacao_params def consolidacao_params
params.require(:consolidacao).permit(:nome, :data_inicio, :data_fim, route_ids: []) params.require(:consolidacao).permit(:nome, :data_inicio, :data_fim, vehicle_ids: [])
end end
end end

View File

@@ -61,7 +61,7 @@ class DashboardController < ApplicationController
} }
end end
# Por operação (route_id → contact_name) # Por local de entrega (contact_name)
@por_operacao = entregas.pagas @por_operacao = entregas.pagas
.group(:contact_name) .group(:contact_name)
.count .count

View File

@@ -1,7 +1,7 @@
# app/jobs/atualizar_historico_estimado_job.rb # app/jobs/atualizar_historico_estimado_job.rb
# #
# Roda a cada 1 hora (via Whenever → rake historico:atualizar). # Roda a cada 1 hora (via Whenever → rake historico:atualizar).
# Lógica: conta entregas do dia com status='completed' E checkin IS NOT NULL, # Lógica: conta entregas do dia com status='completed' E checkout IS NOT NULL,
# multiplica pelo preco_entrega configurado e persiste em historico_estimados. # multiplica pelo preco_entrega configurado e persiste em historico_estimados.
# #
class AtualizarHistoricoEstimadoJob < ApplicationJob class AtualizarHistoricoEstimadoJob < ApplicationJob

View File

@@ -73,14 +73,14 @@ class Consolidacao < ApplicationRecord
end end
# Entregas pagas elegíveis para um motorista no período, respeitando o filtro # Entregas pagas elegíveis para um motorista no período, respeitando o filtro
# de rotas da consolidação (quando houver). Fonte única usada pelo wizard, # de veículos da consolidação (quando houver). Fonte única usada pelo wizard,
# pela revisão e pelo gate de finalização. # pela revisão e pelo gate de finalização.
def entregas_elegiveis_count(motorista) def entregas_elegiveis_count(motorista)
Entrega.contar_pagas( Entrega.contar_pagas(
inicio: data_inicio, inicio: data_inicio,
fim: data_fim, fim: data_fim,
motorista: motorista, motorista: motorista,
route_id: route_ids.presence vehicle: vehicle_ids.presence
) )
end end

View File

@@ -15,8 +15,9 @@ class Entrega < ApplicationRecord
# ── Scopes ────────────────────────────────────────────────── # ── Scopes ──────────────────────────────────────────────────
scope :concluidas, -> { where(status: 'completed') } scope :concluidas, -> { where(status: 'completed') }
scope :com_checkin, -> { where.not(checkin: nil) } scope :com_checkout, -> { where.not(checkout: nil) }
scope :pagas, -> { concluidas.com_checkin } # Pagamento = entrega concluída com checkout registrado
scope :pagas, -> { concluidas.com_checkout }
scope :pendentes, -> { where.not(status: 'completed') } scope :pendentes, -> { where.not(status: 'completed') }
scope :no_periodo, ->(inicio, fim) { scope :no_periodo, ->(inicio, fim) {
@@ -27,8 +28,8 @@ class Entrega < ApplicationRecord
where(driver: nome) where(driver: nome)
} }
scope :da_rota, ->(route_id) { scope :da_veiculo, ->(vehicle) {
where(route_id: route_id) where(vehicle: vehicle)
} }
scope :do_mes, ->(data = Date.current) { scope :do_mes, ->(data = Date.current) {
@@ -48,18 +49,18 @@ class Entrega < ApplicationRecord
base.distinct.order(:driver).pluck(:driver).compact.reject(&:empty?) base.distinct.order(:driver).pluck(:driver).compact.reject(&:empty?)
end end
# Lista rotas únicas (route_id → descrição) # Lista veículos únicos (para selects/filtros de consolidação)
def self.rotas_unicas(inicio: nil, fim: nil) def self.veiculos_unicos(inicio: nil, fim: nil)
base = da_conta_gade base = da_conta_gade
base = base.no_periodo(inicio, fim) if inicio && fim base = base.no_periodo(inicio, fim) if inicio && fim
base.distinct.pluck(:route_id).compact base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?)
end end
# Contagem de entregas pagas para cálculo estimado # Contagem de entregas pagas para cálculo estimado
def self.contar_pagas(inicio:, fim:, motorista: nil, route_id: nil) def self.contar_pagas(inicio:, fim:, motorista: nil, vehicle: nil)
base = pagas.da_conta_gade.no_periodo(inicio, fim) base = pagas.da_conta_gade.no_periodo(inicio, fim)
base = base.do_motorista(motorista) if motorista.present? base = base.do_motorista(motorista) if motorista.present?
base = base.da_rota(route_id) if route_id.present? base = base.da_veiculo(vehicle) if vehicle.present?
base.count base.count
end end
@@ -77,12 +78,12 @@ class Entrega < ApplicationRecord
status == 'completed' status == 'completed'
end end
def checkin_registrado? def checkout_registrado?
checkin.present? checkout.present?
end end
def elegivel_pagamento? def elegivel_pagamento?
concluida? && checkin_registrado? concluida? && checkout_registrado?
end end
def atraso_minutos def atraso_minutos

View File

@@ -85,7 +85,7 @@
NF <%= e.numero_nf %> · <%= e.local %> NF <%= e.numero_nf %> · <%= e.local %>
</p> </p>
<p class="text-gray-500 text-xs"> <p class="text-gray-500 text-xs">
📅 <%= e.planned_date %> · 📍 <%= e.route_id.to_s.first(8) %> 📅 <%= e.planned_date %> · 🚚 <%= e.vehicle.presence || 'sem veículo' %>
<% if e.atraso_minutos > 0 %> · ⏱️ atraso <%= e.atraso_minutos %>min<% end %> <% if e.atraso_minutos > 0 %> · ⏱️ atraso <%= e.atraso_minutos %>min<% end %>
</p> </p>
</div> </div>

View File

@@ -63,16 +63,19 @@
</div> </div>
</div> </div>
<%# Rotas — multi-select opcional %> <%# Veículos — multi-select opcional %>
<div> <div>
<label class="block text-white font-semibold mb-2"> <label class="block text-white font-semibold mb-2">
Rotas <span class="text-gray-500 font-normal text-sm">(opcional — filtra entregas por operação)</span> Veículos <span class="text-gray-500 font-normal text-sm">(opcional — filtra entregas por veículo)</span>
</label> </label>
<div class="bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg p-3 max-h-48 overflow-y-auto space-y-1"> <div class="bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg p-3 max-h-48 overflow-y-auto space-y-1">
<% @rotas.each do |r| %> <% if @veiculos.blank? %>
<label class="flex items-center gap-2 text-white hover:bg-[#1a1a1a] rounded px-2 py-2 cursor-pointer text-sm font-mono min-h-[44px]"> <p class="text-gray-500 text-sm px-2 py-2">Nenhum veículo encontrado no período.</p>
<%= check_box_tag 'consolidacao[route_ids][]', r, false, class: 'rounded text-orange-500 bg-[#1a1a1a] border-[#2a2a2a]', id: nil %> <% end %>
<%= r %> <% @veiculos.each do |v| %>
<label class="flex items-center gap-2 text-white hover:bg-[#1a1a1a] rounded px-2 py-2 cursor-pointer text-sm min-h-[44px]">
<%= check_box_tag 'consolidacao[vehicle_ids][]', v, false, class: 'rounded text-orange-500 bg-[#1a1a1a] border-[#2a2a2a]', id: nil %>
<%= v %>
</label> </label>
<% end %> <% end %>
</div> </div>

View File

@@ -0,0 +1,19 @@
# Consolidação passa a filtrar por VEÍCULO em vez de rota (route_id era um
# UUID pouco legível). Troca a coluna route_ids por vehicle_ids.
class SwapRouteIdsForVehicleIdsOnConsolidacoes < ActiveRecord::Migration[7.1]
def up
unless column_exists?(:consolidacoes, :vehicle_ids)
add_column :consolidacoes, :vehicle_ids, :jsonb, default: []
add_index :consolidacoes, :vehicle_ids, using: :gin
end
remove_column :consolidacoes, :route_ids if column_exists?(:consolidacoes, :route_ids)
end
def down
unless column_exists?(:consolidacoes, :route_ids)
add_column :consolidacoes, :route_ids, :jsonb, default: []
add_index :consolidacoes, :route_ids, using: :gin
end
remove_column :consolidacoes, :vehicle_ids if column_exists?(:consolidacoes, :vehicle_ids)
end
end

View File

@@ -24,14 +24,14 @@ namespace :reem do
puts " status='completed': #{no_periodo.where(status: 'completed').count}" puts " status='completed': #{no_periodo.where(status: 'completed').count}"
puts " checkin preenchido: #{no_periodo.where.not(checkin: nil).count}" puts " checkin preenchido: #{no_periodo.where.not(checkin: nil).count}"
puts " checkout preenchido: #{no_periodo.where.not(checkout: nil).count}" puts " checkout preenchido: #{no_periodo.where.not(checkout: nil).count}"
puts " .pagas (completed + checkin): #{no_periodo.pagas.count}" puts " .pagas (completed + checkout): #{no_periodo.pagas.count}"
puts "-------------------------------------------------" puts "-------------------------------------------------"
puts "planned_date mais antiga: #{Entrega.minimum(:planned_date)}" puts "planned_date mais antiga: #{Entrega.minimum(:planned_date)}"
puts "planned_date mais recente: #{Entrega.maximum(:planned_date)}" puts "planned_date mais recente: #{Entrega.maximum(:planned_date)}"
puts "=================================================" puts "================================================="
puts "Leitura:" puts "Leitura:"
puts " • 'SEM filtro' > 0 e 'com filtro Gade' = 0 → ajuste DB_EXISTING_ACCOUNT_ID no .env" puts " • 'SEM filtro' > 0 e 'com filtro Gade' = 0 → ajuste DB_EXISTING_ACCOUNT_ID no .env"
puts " • total > 0 mas '.pagas' = 0 (checkin vazio) regra de pagamento deveria usar checkout" puts " • total > 0 mas '.pagas' = 0 (checkout vazio) → entregas sem checkout não contam como pagas"
puts " • tudo 0 → não há dados nesse período (veja datas acima)" puts " • tudo 0 → não há dados nesse período (veja datas acima)"
rescue => e rescue => e
warn "[diagnostico] Falha: #{e.class} - #{e.message}" warn "[diagnostico] Falha: #{e.class} - #{e.message}"