diff --git a/Imagens para correção /Imagem colada (2).png b/Imagens para correção /Imagem colada (2).png new file mode 100644 index 0000000..691e54a Binary files /dev/null and b/Imagens para correção /Imagem colada (2).png differ diff --git a/Imagens para correção /Imagem colada (3).png b/Imagens para correção /Imagem colada (3).png new file mode 100644 index 0000000..f965c88 Binary files /dev/null and b/Imagens para correção /Imagem colada (3).png differ diff --git a/Imagens para correção /Imagem colada (4).png b/Imagens para correção /Imagem colada (4).png new file mode 100644 index 0000000..7943b09 Binary files /dev/null and b/Imagens para correção /Imagem colada (4).png differ diff --git a/Imagens para implantação /Imagem colada (11).png b/Imagens para implantação /Imagem colada (11).png new file mode 100644 index 0000000..8346d7a Binary files /dev/null and b/Imagens para implantação /Imagem colada (11).png differ diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb index c0c4742..a0092d0 100644 --- a/app/controllers/consolidacao_entregas_controller.rb +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -88,6 +88,7 @@ class ConsolidacaoEntregasController < ApplicationController motorista_nome: params[:motorista], tipo: params[:tipo], valor_aplicado: valor_classificacao(params[:tipo]), + vehicle: veiculo_do_tracking(params[:tracking_id]), # snapshot p/ fechar por veículo created_by: current_user.id ) ativo = true @@ -132,11 +133,13 @@ class ConsolidacaoEntregasController < ApplicationController .destroy_all else # ADICIONA o pilar às entregas (não remove os demais já marcados). - valor = valor_classificacao(tipo) + valor = valor_classificacao(tipo) + veiculos = veiculos_dos_trackings(tracking_ids) # tracking_id => vehicle (snapshot) tracking_ids.each do |tid| ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: tid, tipo: tipo) ce.assign_attributes(motorista_nome: motorista, valor_aplicado: valor, created_by: current_user.id) + ce.vehicle = veiculos[tid] if ce.vehicle.blank? ce.save! end end @@ -246,6 +249,7 @@ class ConsolidacaoEntregasController < ApplicationController nf: params[:nf], obs: params[:obs], valor_extra: valor_extraordinaria_param, + vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3) user: current_user ) auditar!(:editar, @consolidacao, @@ -271,6 +275,7 @@ class ConsolidacaoEntregasController < ApplicationController motorista = @consolidacao.adicionar_termos!( motorista: params[:motorista].to_s.strip, quantidade: params[:quantidade], + vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3) user: current_user ) auditar!(:editar, @consolidacao, @@ -315,12 +320,104 @@ class ConsolidacaoEntregasController < ApplicationController end end + # POST /consolidacoes/:consolidacao_id/consolidacao_entregas/fechar_veiculo + # Consolida (fecha) UM veículo do motorista. Exige que o veículo esteja 100% + # classificado. O pagamento continua sendo por motorista (não é pago aqui). + def fechar_veiculo + authorize @consolidacao, :update? + motorista = params[:motorista] + veiculo = params[:vehicle].to_s + return render_veiculo_erro('Veículo/motorista inválido.') unless motorista_na_consolidacao?(motorista) && veiculo.present? + + status = veiculos_status(motorista).find { |v| v[:vehicle] == veiculo } + return render_veiculo_erro('Classifique todas as entregas deste veículo antes de consolidar.') if status && !status[:completo] + + cv = @consolidacao.consolidacao_veiculos.find_or_create_by!(motorista_nome: motorista, vehicle: veiculo) + cv.fechar!(current_user) + auditar!(:editar, @consolidacao, dados_novos: { fechar_veiculo: veiculo, motorista: motorista }) + + responder_veiculo(motorista, "Veículo #{veiculo} consolidado.") + end + + # POST .../reabrir_veiculo — reabre um veículo já consolidado (reversível). + def reabrir_veiculo + authorize @consolidacao, :update? + motorista = params[:motorista] + cv = @consolidacao.consolidacao_veiculos.find_by(motorista_nome: motorista, vehicle: params[:vehicle].to_s) + return render_veiculo_erro('Veículo não encontrado.') unless cv + + cv.reabrir!(current_user) + auditar!(:editar, @consolidacao, dados_novos: { reabrir_veiculo: cv.vehicle, motorista: motorista }) + responder_veiculo(motorista, "Veículo #{cv.vehicle} reaberto.") + end + + # POST .../arquivar_veiculo — tira um veículo do gate de finalização (reversível). + # As entregas já classificadas dele continuam somando no pagamento do motorista. + def arquivar_veiculo + authorize @consolidacao, :update? + motorista = params[:motorista] + veiculo = params[:vehicle].to_s + return render_veiculo_erro('Veículo/motorista inválido.') unless motorista_na_consolidacao?(motorista) && veiculo.present? + + cv = @consolidacao.consolidacao_veiculos.find_or_create_by!(motorista_nome: motorista, vehicle: veiculo) + cv.arquivar!(current_user) + auditar!(:editar, @consolidacao, dados_novos: { arquivar_veiculo: veiculo, motorista: motorista }) + responder_veiculo(motorista, "Veículo #{veiculo} arquivado — fora da finalização.") + end + + # POST .../desarquivar_veiculo — restaura um veículo arquivado. + def desarquivar_veiculo + authorize @consolidacao, :update? + motorista = params[:motorista] + cv = @consolidacao.consolidacao_veiculos.find_by(motorista_nome: motorista, vehicle: params[:vehicle].to_s) + return render_veiculo_erro('Veículo não encontrado.') unless cv + + cv.desarquivar!(current_user) + auditar!(:editar, @consolidacao, dados_novos: { desarquivar_veiculo: cv.vehicle, motorista: motorista }) + responder_veiculo(motorista, "Veículo #{cv.vehicle} restaurado.") + end + private def set_consolidacao @consolidacao = Consolidacao.find(params[:consolidacao_id]) end + # Resposta padrão das ações de veículo: JSON com o resumo atualizado (barra + + # chips com o novo estado) ou redirect de volta pra tela de validação. + def responder_veiculo(motorista, aviso) + respond_to do |format| + format.json { render json: resumo_json(motorista, vehicles: params[:vehicle_sel]) } + format.html do + redirect_to validar_consolidacao_consolidacao_entregas_path( + @consolidacao, motorista: motorista, vehicle: veiculos_param(params[:vehicle_sel]).presence + ), notice: aviso + end + end + end + + def render_veiculo_erro(msg) + respond_to do |format| + format.json { render json: { ok: false, erro: msg }, status: :unprocessable_entity } + format.html do + redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: params[:motorista]), + alert: msg + end + end + end + + # Veículo (snapshot da rastreio) de UM tracking_id. nil para sintéticos. + def veiculo_do_tracking(tracking_id) + Entrega.da_conta_gade.find_by(tracking_id: tracking_id)&.vehicle + end + + # Mapa tracking_id => vehicle para um lote (uma query só). + def veiculos_dos_trackings(ids) + return {} if ids.blank? + + Entrega.da_conta_gade.where(tracking_id: ids).pluck(:tracking_id, :vehicle).to_h + end + def bloquear_se_finalizada return unless @consolidacao.finalizada? && current_user.operador? redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.' @@ -367,26 +464,9 @@ class ConsolidacaoEntregasController < ApplicationController # { vehicle:, eligible:, classificadas:, fechado: }. Fonte única do filtro de # carros e do acompanhamento (chips). 2 queries: elegíveis (com a coluna do # veículo) e as já classificadas (não-manuais). + # Status por veículo do motorista (fonte única no model: Consolidacao#veiculos_do_motorista). def veiculos_status(motorista) - return [] unless motorista.present? - - rel = Entrega.atendidas.da_conta_gade - .no_periodo_checkout(@consolidacao.data_inicio, @consolidacao.data_fim) - .do_motorista(motorista) - rel = rel.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present? - rel = rel.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present? - - por_veiculo = rel.pluck(:tracking_id, :vehicle).group_by { |(_id, v)| v.presence || SEM_VEICULO } - classificadas = @consolidacao.consolidacao_entregas - .where(motorista_nome: motorista, manual: false) - .distinct.pluck(:tracking_id).to_set - - por_veiculo.map do |veiculo, linhas| - ids = linhas.map(&:first) - feito = ids.count { |id| classificadas.include?(id) } - { vehicle: veiculo, eligible: ids.size, classificadas: feito, - fechado: ids.size.positive? && feito >= ids.size } - end.sort_by { |v| v[:vehicle] } + @consolidacao.veiculos_do_motorista(motorista) end # Valida que a entrega pertence ao motorista informado e ao período/veículo da diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index 2aad23c..b8fe1cd 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -3,7 +3,7 @@ class ConsolidacoesController < ApplicationController include Auditavel before_action :set_consolidacao, - only: %i[show edit update destroy finalizar arquivar reativar wizard + only: %i[show edit update destroy finalizar finalizar_parcial arquivar reativar wizard registrar_pagamento cancelar_pagamento arquivar_motorista desarquivar_motorista excluir_motorista adicionar_motorista @@ -159,6 +159,10 @@ class ConsolidacoesController < ApplicationController @apontamentos = @consolidacao.consolidacao_entregas.apontamentos .order(:motorista_nome, :tracking_id) .group_by { |ce| [ce.motorista_nome, ce.tracking_id] } + # Veículos consolidados por motorista (breakdown informativo no card). + @veiculos_por_motorista = @consolidacao.consolidacao_veiculos.ativos + .order(:vehicle) + .group_by(&:motorista_nome) end # GET /consolidacoes/:id/wizard — PASSO 1: selecionar motorista @@ -177,14 +181,20 @@ class ConsolidacoesController < ApplicationController .apontamentos .where(motorista_nome: cm.motorista_nome) .distinct.count(:tracking_id) + # Status por veículo (breakdown do card): total usados, consolidados (🔒), + # pendentes (não dá p/ consolidar ainda) e prontos (100%, falta consolidar). + vinfo = @consolidacao.veiculos_do_motorista(cm.motorista_nome).reject { |v| v[:arquivado] } { - registro: cm, - total: total, - classificadas: classificadas, - apontamentos: apontamentos, - # Espelha o gate do servidor (todas_entregas_classificadas?): um motorista - # só com apontamento manual tem 0 entregas elegíveis e já está "completo". - completo: total.zero? || classificadas >= total + registro: cm, + total: total, + classificadas: classificadas, + apontamentos: apontamentos, + veiculos_total: vinfo.size, + veiculos_fechados: vinfo.count { |v| v[:fechado] }, + veiculos_prontos: vinfo.count { |v| !v[:fechado] && v[:completo] }, + veiculos_pendentes: vinfo.select { |v| !v[:fechado] && !v[:completo] && v[:eligible].positive? } + .map { |v| v[:vehicle] }, + completo: @consolidacao.motorista_completo?(cm.motorista_nome) } end end @@ -199,7 +209,26 @@ class ConsolidacoesController < ApplicationController redirect_to @consolidacao, notice: 'Consolidação finalizada! Motoristas notificados.' else redirect_to wizard_consolidacao_path(@consolidacao), - alert: 'Não é possível finalizar: existem entregas não classificadas.' + alert: 'Não é possível finalizar: ainda há entregas não classificadas. Classifique-as ou arquive o veículo pendente.' + end + end + + # POST /consolidacoes/:id/finalizar_parcial + # Atalho "finalizar só os veículos consolidados": arquiva os veículos que ainda + # não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os + # veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora. + def finalizar_parcial + authorize @consolidacao, :update? + + arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user) + if @consolidacao.finalizar!(current_user) + auditar!(:finalizar, @consolidacao, dados_novos: { finalizar_parcial: true, veiculos_arquivados: arquivados }) + NotificacaoService.notificar_finalizacao(@consolidacao) + redirect_to @consolidacao, + notice: "Consolidação finalizada. #{arquivados.size} veículo(s) pendente(s) ficaram de fora. Motoristas notificados." + else + redirect_to wizard_consolidacao_path(@consolidacao), + alert: 'Não foi possível finalizar: ainda há entregas pendentes fora dos veículos arquivados.' end end @@ -320,6 +349,7 @@ class ConsolidacoesController < ApplicationController nome = cm.motorista_nome ActiveRecord::Base.transaction do @consolidacao.consolidacao_entregas.where(motorista_nome: nome).delete_all + @consolidacao.consolidacao_veiculos.where(motorista_nome: nome).delete_all cm.destroy! end # cm já não existe — atualiza o total da consolidação direto (soma dos ativos). diff --git a/app/javascript/controllers/apontamento_manual_controller.js b/app/javascript/controllers/apontamento_manual_controller.js index 36c3910..6dd0e51 100644 --- a/app/javascript/controllers/apontamento_manual_controller.js +++ b/app/javascript/controllers/apontamento_manual_controller.js @@ -7,7 +7,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["modal", "motorista", "nf", "obs", "erro", "tipoCheck", "valorExtra"] + static targets = ["modal", "motorista", "nf", "obs", "erro", "tipoCheck", "valorExtra", "veiculo"] static values = { apontarManualUrl: String } abrir() { @@ -52,7 +52,8 @@ export default class extends Controller { nf: this.nfTarget.value.trim(), obs: this.obsTarget.value.trim(), tipos: tipos, - valor: valor + valor: valor, + vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : "" }) }) const data = await resp.json() diff --git a/app/javascript/controllers/apontamento_termo_controller.js b/app/javascript/controllers/apontamento_termo_controller.js index aea439d..260bd55 100644 --- a/app/javascript/controllers/apontamento_termo_controller.js +++ b/app/javascript/controllers/apontamento_termo_controller.js @@ -6,7 +6,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["modal", "quantidade", "erro"] + static targets = ["modal", "quantidade", "erro", "veiculo"] static values = { apontarTermoUrl: String, motorista: String } abrir() { @@ -55,7 +55,11 @@ export default class extends Controller { "Accept": "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }, - body: JSON.stringify({ motorista: motorista, quantidade: quantidade }) + body: JSON.stringify({ + motorista: motorista, + quantidade: quantidade, + vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : "" + }) }) const data = await resp.json() if (!resp.ok || data.ok === false) { diff --git a/app/javascript/controllers/validacao_controller.js b/app/javascript/controllers/validacao_controller.js index aab3bc9..a8c3c79 100644 --- a/app/javascript/controllers/validacao_controller.js +++ b/app/javascript/controllers/validacao_controller.js @@ -9,6 +9,7 @@ export default class extends Controller { "linha", "checkbox", "selecionarTodos", "busca", "buscaVazia", "resumo", "listaCol", "btnResumoLabel", "toggles", "barra", "barraManuais", "percentual", "chipVeiculo", + "consolidarBtn", "consolidarHint", "contadorClassificadas", "contadorTotal", "contadorManuais", "manuaisInfo", "qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "qtdExtraordinaria", "qtdTermo", "qtdManuais", "valorTotal" @@ -217,6 +218,23 @@ export default class extends Controller { this.barraTarget.classList.toggle("bg-green-500", pct >= 100) this.barraTarget.classList.toggle("bg-orange-500", pct < 100) + // Botão "Consolidar este veículo" (só existe quando UM veículo está + // selecionado e ainda não fechado): habilita quando o escopo chega a 100%. + if (this.hasConsolidarBtnTarget) { + const pronto = pct >= 100 + this.consolidarBtnTarget.disabled = !pronto + this.consolidarBtnTarget.classList.toggle("bg-green-600", pronto) + this.consolidarBtnTarget.classList.toggle("hover:bg-green-500", pronto) + this.consolidarBtnTarget.classList.toggle("text-white", pronto) + this.consolidarBtnTarget.classList.toggle("cursor-pointer", pronto) + this.consolidarBtnTarget.classList.toggle("bg-[#2a2a2a]", !pronto) + this.consolidarBtnTarget.classList.toggle("text-gray-600", !pronto) + this.consolidarBtnTarget.classList.toggle("cursor-not-allowed", !pronto) + if (this.hasConsolidarHintTarget) + this.consolidarHintTarget.textContent = + pronto ? "Pronto para consolidar." : "Classifique 100% deste veículo para consolidar." + } + if (dados.veiculos_status) this.atualizarChips(dados.veiculos_status) } @@ -227,9 +245,12 @@ export default class extends Controller { const porVeiculo = {} status.forEach(v => { porVeiculo[v.vehicle] = v }) - const VERDE = ["border-green-500", "text-green-400", "bg-green-500/10"] - const LARANJA = ["border-orange-500", "text-orange-400", "bg-orange-500/10"] - const NEUTRO = ["border-[#2a2a2a]", "text-gray-300"] + const LARANJA = ["border-orange-500", "text-orange-400", "bg-orange-500/10"] + const VERDE_FECHADO = ["border-green-500", "text-green-400", "bg-green-500/10"] + const VERDE_PRONTO = ["border-green-600/50", "text-green-400"] + const ARQUIVADO = ["border-dashed", "border-[#3a3a3a]", "text-gray-500"] + const NEUTRO = ["border-[#2a2a2a]", "text-gray-300"] + const TODAS = [...LARANJA, ...VERDE_FECHADO, ...VERDE_PRONTO, ...ARQUIVADO, ...NEUTRO] this.chipVeiculoTargets.forEach(chip => { const dados = porVeiculo[chip.dataset.vehicle] @@ -237,17 +258,23 @@ export default class extends Controller { const feito = chip.querySelector("[data-chip-feito]") const total = chip.querySelector("[data-chip-total]") const check = chip.querySelector("[data-chip-check]") + const lock = chip.querySelector("[data-chip-lock]") if (feito) feito.textContent = dados.classificadas if (total) total.textContent = dados.eligible - if (check) check.textContent = dados.fechado ? "✓" : "" + if (check) check.textContent = dados.completo ? "✓" : "" + if (lock) lock.classList.toggle("hidden", !dados.fechado) - // Estado de cor determinístico: laranja (selecionado) > verde (fechado) > neutro. - // Seleção tem prioridade para o usuário sempre enxergar quais carros somam. - chip.classList.remove(...VERDE, ...LARANJA, ...NEUTRO) + // Cor determinística: selecionado(laranja) > consolidado(verde+🔒) > + // pronto(verde) > arquivado(cinza) > neutro. + chip.classList.remove(...TODAS) if (this.veiculosValue.includes(chip.dataset.vehicle)) { chip.classList.add(...LARANJA) } else if (dados.fechado) { - chip.classList.add(...VERDE) + chip.classList.add(...VERDE_FECHADO) + } else if (dados.completo) { + chip.classList.add(...VERDE_PRONTO) + } else if (dados.arquivado) { + chip.classList.add(...ARQUIVADO) } else { chip.classList.add(...NEUTRO) } diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index 45b3a15..8bb25d9 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -8,6 +8,11 @@ class Consolidacao < ApplicationRecord has_many :consolidacao_motoristas, dependent: :destroy has_many :consolidacao_entregas, dependent: :destroy + has_many :consolidacao_veiculos, dependent: :destroy + + # Bucket dos lançamentos sem veículo (termo/apontamento manual legado). Mesmo + # sentinel do controller e de ConsolidacaoVeiculo. + SEM_VEICULO = ConsolidacaoVeiculo::SEM_VEICULO # ── Enums ─────────────────────────────────────────────────── enum status: { rascunho: 0, finalizada: 1, arquivada: 2 } @@ -111,21 +116,34 @@ class Consolidacao < ApplicationRecord def finalizar!(user) return false unless todas_entregas_classificadas? - update!( - status: :finalizada, - finalizado_por: user.id, - finalizado_em: Time.current - ) + transaction do + # Finalizar a consolidação fecha (consolida) os veículos que ainda estavam + # abertos — mantém o estado por veículo coerente com o todo finalizado. + consolidacao_veiculos.ativos.abertos.update_all( + fechado_em: Time.current, fechado_por: user.id, updated_at: Time.current + ) + update!( + status: :finalizada, + finalizado_por: user.id, + finalizado_em: Time.current + ) + end end - # Só está completa quando CADA motorista tem todas as suas entregas elegíveis - # classificadas. (Antes era uma soma agregada com >=, que mascarava motoristas - # pendentes quando outro tinha entregas a mais.) + # Só está completa quando CADA motorista ativo está completo (nada por + # classificar, descontados os veículos arquivados). Fechar veículo é um passo + # independente; ao finalizar, os veículos abertos são consolidados juntos. def todas_entregas_classificadas? - consolidacao_motoristas.ativos.all? do |cm| - total = entregas_elegiveis_count(cm.motorista_nome) - total.zero? || classificadas_count(cm.motorista_nome) >= total - end + consolidacao_motoristas.ativos.all? { |cm| motorista_completo?(cm.motorista_nome) } + end + + # Completude de UM motorista: não sobra entrega elegível por classificar. Os + # veículos ARQUIVADOS saem da conta (suas entregas deixam de ser exigidas), + # permitindo finalizar deixando um carro problemático de fora. Fechar um veículo + # é um passo independente (lock) — a finalização em si fecha os que restarem. + def motorista_completo?(motorista) + total = entregas_elegiveis_count(motorista) - eligible_veiculos_arquivados(motorista) + total.zero? || classificadas_count(motorista) >= total end def percentual_classificado @@ -153,6 +171,28 @@ class Consolidacao < ApplicationRecord ) end + # Elegíveis de UM veículo específico do motorista (respeita filtros da + # consolidação). O bucket "(sem veículo)" casa as entregas com vehicle NULL/vazio. + def entregas_elegiveis_count_veiculo(motorista, vehicle) + rel = Entrega.atendidas.da_conta_gade + .no_periodo_checkout(data_inicio, data_fim) + .do_motorista(motorista) + rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present? + rel = rel.da_operacoes(operacoes) if operacoes.present? + rel = vehicle == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.where(vehicle: vehicle) + rel.count + end + + # Total de elegíveis dos veículos ARQUIVADOS do motorista — descontado do gate + # para que arquivar um carro problemático libere a finalização. Só toca a base + # externa quando existe algum veículo arquivado (mantém os testes isolados). + def eligible_veiculos_arquivados(motorista) + arquivados = consolidacao_veiculos.arquivados.where(motorista_nome: motorista) + return 0 if arquivados.empty? + + arquivados.sum { |cv| entregas_elegiveis_count_veiculo(motorista, cv.vehicle) } + end + # Rótulos amigáveis das operações vinculadas (para exibição). def operacoes_label Array(operacoes).map { |t| Operacao.label(t) } @@ -168,6 +208,54 @@ class Consolidacao < ApplicationRecord .distinct.count(:tracking_id) end + # Status por veículo do motorista: array de { vehicle, eligible, classificadas, + # completo, fechado, arquivado }. `completo` = 100% classificado (pode consolidar); + # `fechado` = veículo consolidado (🔒); `arquivado` = fora do gate. Une os veículos + # com entregas elegíveis (rastreio) com os que só têm linha em consolidacao_veiculos + # (arquivados / buckets manuais). Fonte única do filtro de carros e do wizard. + def veiculos_do_motorista(motorista) + return [] unless motorista.present? + + rel = Entrega.atendidas.da_conta_gade + .no_periodo_checkout(data_inicio, data_fim) + .do_motorista(motorista) + rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present? + rel = rel.da_operacoes(operacoes) if operacoes.present? + + por_veiculo = rel.pluck(:tracking_id, :vehicle).group_by { |(_id, v)| v.presence || SEM_VEICULO } + classificadas = consolidacao_entregas.where(motorista_nome: motorista, manual: false) + .distinct.pluck(:tracking_id).to_set + estados = consolidacao_veiculos.where(motorista_nome: motorista).index_by(&:vehicle) + + (por_veiculo.keys + estados.keys).uniq.map do |veiculo| + ids = (por_veiculo[veiculo] || []).map(&:first) + feito = ids.count { |id| classificadas.include?(id) } + cv = estados[veiculo] + { vehicle: veiculo, eligible: ids.size, classificadas: feito, + completo: ids.size.positive? && feito >= ids.size, + fechado: cv&.fechado? || false, + arquivado: cv&.arquivado? || false } + end.sort_by { |v| v[:vehicle] } + end + + # Arquiva os veículos PENDENTES (com entregas elegíveis mas não 100% classificadas) + # de todos os motoristas ativos, deixando-os de fora da finalização. Usado pelo + # atalho "arquivar pendentes e finalizar" — os prontos/consolidados seguem no todo. + # Retorna a lista "Motorista · Veículo" arquivada. + def arquivar_veiculos_pendentes!(user) + arquivados = [] + consolidacao_motoristas.ativos.each do |cm| + veiculos_do_motorista(cm.motorista_nome).each do |v| + next if v[:fechado] || v[:arquivado] || v[:completo] || v[:eligible].zero? + + cv = consolidacao_veiculos.find_or_create_by!(motorista_nome: cm.motorista_nome, vehicle: v[:vehicle]) + cv.arquivar!(user) + arquivados << "#{cm.motorista_nome} · #{v[:vehicle]}" + end + end + arquivados + end + # ── Apontamento manual ────────────────────────────────────── # Adiciona uma entrega "por fora" (NF que chegou depois) com um ou mais pilares # (tipos). O motorista vem da própria entrega e é incluído na consolidação se @@ -190,6 +278,7 @@ class Consolidacao < ApplicationRecord motorista_nome: motorista, valor_aplicado: ConsolidacaoEntrega.valor_para(tipo), manual: true, + vehicle: entrega.try(:vehicle).presence, # snapshot do veículo da rastreio created_by: user&.id ) ce.save! @@ -208,7 +297,7 @@ class Consolidacao < ApplicationRecord # O motorista vem digitado (não de uma Entrega) e é incluído se ainda não # estiver. tracking_id é sintético (não há Entrega), e a NF/observação ficam # guardadas em nf_manual/obs_manual. - def adicionar_apontamento_manual!(motorista:, tipos:, user:, nf: nil, obs: nil, valor_extra: nil) + def adicionar_apontamento_manual!(motorista:, tipos:, user:, nf: nil, obs: nil, valor_extra: nil, vehicle: nil) tipos_validos = Array(tipos).map(&:to_s).select { |t| ConsolidacaoEntrega.tipos.key?(t) }.uniq raise ArgumentError, 'Selecione ao menos uma classificação.' if tipos_validos.empty? @@ -232,6 +321,7 @@ class Consolidacao < ApplicationRecord motorista_nome: nome, valor_aplicado: valor, manual: true, + vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3) nf_manual: nf.presence, obs_manual: obs.presence, created_by: user&.id @@ -249,7 +339,7 @@ class Consolidacao < ApplicationRecord # para atrelar (só motorista + quantidade, a um preço fixo configurável). Fica # numa única linha (manual: true): quantidade guarda o N e valor_aplicado guarda # o total do lote, então recalcular_motorista! e demais somas seguem inalteradas. - def adicionar_termos!(motorista:, quantidade:, user:) + def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil) nome = motorista.to_s.strip raise ArgumentError, 'Informe o motorista do termo.' if nome.blank? @@ -266,6 +356,7 @@ class Consolidacao < ApplicationRecord quantidade: qtd, valor_aplicado: qtd * ConsolidacaoEntrega.valor_para('termo'), # total do lote manual: true, + vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3) created_by: user&.id ) @@ -286,6 +377,27 @@ class Consolidacao < ApplicationRecord end cm.update_column(:valor_total, total) update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total)) + sincronizar_veiculos!(nome) + end + + # Mantém as linhas de consolidacao_veiculos do motorista em dia a partir dos + # pilares já classificados (100% DB — não toca a base externa). Cria/atualiza uma + # linha por veículo presente nas classificações e remove as que ficaram sem + # pilares e ainda não foram fechadas/arquivadas. Veículos elegíveis mas 100% sem + # classificar não geram linha aqui — o gate já os pega por classificadas < total. + def sincronizar_veiculos!(nome) + entregas = consolidacao_entregas.where(motorista_nome: nome) + presentes = entregas.pluck(:vehicle).map { |v| v.presence || SEM_VEICULO }.uniq + + presentes.each do |veic| + cv = consolidacao_veiculos.find_or_initialize_by(motorista_nome: nome, vehicle: veic) + cv.save! # before_save :recalcular_valor atualiza o valor_total do veículo + end + + consolidacao_veiculos.where(motorista_nome: nome) + .where(fechado_em: nil, arquivado_em: nil) + .where.not(vehicle: presentes) + .destroy_all end private diff --git a/app/models/consolidacao_veiculo.rb b/app/models/consolidacao_veiculo.rb new file mode 100644 index 0000000..b88cb17 --- /dev/null +++ b/app/models/consolidacao_veiculo.rb @@ -0,0 +1,71 @@ +# app/models/consolidacao_veiculo.rb +# Unidade de FECHAMENTO por veículo dentro de uma consolidação. Espelha os padrões +# de ConsolidacaoMotorista (arquivamento reversível + valor_total recalculado dos +# pilares), mas o que ela controla é o estado "fechado/consolidado" de cada carro. +# O PAGAMENTO continua no motorista (ConsolidacaoMotorista) — ver [[consolidacao]]. +class ConsolidacaoVeiculo < ApplicationRecord + # Nome da tabela fixado (mesmo motivo dos outros: inflector pt-BR). + self.table_name = 'consolidacao_veiculos' + + # Bucket dos lançamentos sem veículo (termo/apontamento manual legado). Igual ao + # sentinel usado no controller e na migração — NULL quebraria o índice único. + SEM_VEICULO = '(sem veículo)' + + belongs_to :consolidacao + belongs_to :fechador, class_name: 'User', foreign_key: :fechado_por, optional: true + belongs_to :arquivador, class_name: 'User', foreign_key: :arquivado_por, optional: true + + validates :motorista_nome, presence: true + validates :vehicle, presence: true + validates :vehicle, uniqueness: { scope: %i[consolidacao_id motorista_nome] } + + before_save :recalcular_valor + + # ── Fechamento (consolidação do veículo, reversível) ──────── + scope :fechados, -> { where.not(fechado_em: nil) } + scope :abertos, -> { where(fechado_em: nil) } + + # ── Arquivamento (reversível) — sai do gate, mantém os dados ─ + scope :ativos, -> { where(arquivado_em: nil) } + scope :arquivados, -> { where.not(arquivado_em: nil) } + + def fechado? + fechado_em.present? + end + + def arquivado? + arquivado_em.present? + end + + # update_columns: não dispara before_save (o valor já está correto no fechamento). + def fechar!(user) + update_columns(fechado_em: Time.current, fechado_por: user&.id, updated_at: Time.current) + end + + def reabrir!(_user) + update_columns(fechado_em: nil, fechado_por: nil, updated_at: Time.current) + end + + def arquivar!(user) + update_columns(arquivado_em: Time.current, arquivado_por: user&.id, updated_at: Time.current) + end + + def desarquivar!(_user) + update_columns(arquivado_em: nil, arquivado_por: nil, updated_at: Time.current) + end + + # Soma dos pilares deste (motorista + veículo). Mesma regra do ConsolidacaoMotorista: + # o valor_aplicado capturado na classificação é a fonte da verdade e o desconto + # subtrai. O bucket "(sem veículo)" casa os pilares com vehicle NULL/vazio. + def recalcular_valor + self.valor_total = escopo_entregas + .sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado } + end + + private + + def escopo_entregas + rel = consolidacao.consolidacao_entregas.where(motorista_nome: motorista_nome) + vehicle == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.where(vehicle: vehicle) + end +end diff --git a/app/models/operacao.rb b/app/models/operacao.rb index 9fbc56b..3620036 100644 --- a/app/models/operacao.rb +++ b/app/models/operacao.rb @@ -37,17 +37,28 @@ class Operacao nomes_validos.include?(nome.to_s) end - # Rótulo amigável: tira o prefixo e formata. + # Rótulo amigável: tira o prefixo e formata em CAIXA ALTA (para não parecer o + # nome cru da tabela do banco). def label(tabela) - tabela.to_s.delete_prefix(PREFIXO).tr('_', ' ').strip.titleize + tabela.to_s.delete_prefix(PREFIXO).tr('_', ' ').strip.upcase end - # Abreviações de mês usadas nos nomes das tabelas (gade_entregas_emad_fev_2026). + # Meses usados nos nomes das tabelas (gade_entregas_emad_fev_2026). Aceita a + # abreviação (fev) E o nome por extenso (fevereiro) — o cliente usa os dois + # (ex.: "..._ubs_norte_maio_2026"), então ambos precisam cair no mês certo. MESES = { - 'jan' => [1, 'Janeiro'], 'fev' => [2, 'Fevereiro'], 'mar' => [3, 'Março'], - 'abr' => [4, 'Abril'], 'mai' => [5, 'Maio'], 'jun' => [6, 'Junho'], - 'jul' => [7, 'Julho'], 'ago' => [8, 'Agosto'], 'set' => [9, 'Setembro'], - 'out' => [10, 'Outubro'], 'nov' => [11, 'Novembro'], 'dez' => [12, 'Dezembro'] + 'jan' => [1, 'Janeiro'], 'janeiro' => [1, 'Janeiro'], + 'fev' => [2, 'Fevereiro'], 'fevereiro' => [2, 'Fevereiro'], + 'mar' => [3, 'Março'], 'marco' => [3, 'Março'], 'março' => [3, 'Março'], + 'abr' => [4, 'Abril'], 'abril' => [4, 'Abril'], + 'mai' => [5, 'Maio'], 'maio' => [5, 'Maio'], + 'jun' => [6, 'Junho'], 'junho' => [6, 'Junho'], + 'jul' => [7, 'Julho'], 'julho' => [7, 'Julho'], + 'ago' => [8, 'Agosto'], 'agosto' => [8, 'Agosto'], + 'set' => [9, 'Setembro'], 'setembro' => [9, 'Setembro'], + 'out' => [10, 'Outubro'], 'outubro' => [10, 'Outubro'], + 'nov' => [11, 'Novembro'], 'novembro' => [11, 'Novembro'], + 'dez' => [12, 'Dezembro'], 'dezembro' => [12, 'Dezembro'] }.freeze # Agrupa as operações por mês/ano (extraído do nome da tabela), do mais recente @@ -99,7 +110,7 @@ class Operacao resto = (tokens[0...idx] + tokens[(ano ? idx + 2 : idx + 1)..]).join(' ').strip { titulo: ano ? "#{nome} #{ano}" : nome, - item: resto.presence&.titleize || nome, + item: (resto.presence || nome).to_s.upcase, # mais recente primeiro; sem ano vai pro fim do bloco do mês ordem: [-(ano || 0), -num] } diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb index 8aa148a..1ad2c76 100644 --- a/app/views/consolidacao_entregas/validar.html.erb +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -46,13 +46,15 @@ - <%# Fechar por veículo — filtra a tela por carro e mostra quais já estão fechados. + <%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/ + consolidado/arquivado) e permite CONSOLIDAR o veículo selecionado. Só aparece quando o motorista usou mais de um veículo no período. %> + <% pode_fechar_veiculo = !(@consolidacao.finalizada? && current_user.operador?) %> <% if @veiculos_status.size > 1 %>
-
+
🚗 Fechar por veículo: - clique para filtrar/somar um ou vários carros (clique de novo p/ tirar) + clique no nome para filtrar; consolide o carro quando estiver 100% (🔒)
<%# Chip "Todos" — limpa a seleção (mostra todos os carros) %> @@ -66,18 +68,68 @@ <% selecionado = @veiculos_sel.include?(v[:vehicle]) %> <%# Alterna este veículo dentro da seleção atual (multi-seleção) %> <% nova_sel = selecionado ? (@veiculos_sel - [v[:vehicle]]) : (@veiculos_sel + [v[:vehicle]]) %> + <%# Cor determinística: selecionado(laranja) > consolidado(verde+🔒) > + pronto(verde) > arquivado(cinza tracejado) > neutro. %> + <% cor = if selecionado then 'border-orange-500 text-orange-400 bg-orange-500/10' + elsif v[:fechado] then 'border-green-500 text-green-400 bg-green-500/10' + elsif v[:completo] then 'border-green-600/50 text-green-400' + elsif v[:arquivado] then 'border-dashed border-[#3a3a3a] text-gray-500' + else 'border-[#2a2a2a] text-gray-300 hover:border-orange-500' end %> <%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: nova_sel.presence), data: { 'validacao-target': 'chipVeiculo', vehicle: v[:vehicle], eligible: v[:eligible] }, - class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center gap-2 transition-colors - #{selecionado ? 'border-orange-500 text-orange-400 bg-orange-500/10' : (v[:fechado] ? 'border-green-500 text-green-400 bg-green-500/10' : 'border-[#2a2a2a] text-gray-300 hover:border-orange-500')}" do %> + class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center gap-2 transition-colors #{cor}" do %> + 🔒 <%= v[:vehicle] %> - + <%= v[:classificadas] %>/<%= v[:eligible] %> - <%= '✓' if v[:fechado] %> + <%= '✓' if v[:completo] %> <% end %> <% end %>
+ + <%# Ação de consolidação — opera sobre o veículo único selecionado. %> + <% if pode_fechar_veiculo %> + <% sel_unico = @veiculos_sel.size == 1 ? @veiculos_sel.first : nil %> + <% status_unico = sel_unico && @veiculos_status.find { |v| v[:vehicle] == sel_unico } %> +
+ <% if status_unico.nil? %> +

Selecione um veículo (clique no nome) para consolidá-lo separadamente.

+ <% elsif status_unico[:fechado] %> +
+ 🔒 <%= sel_unico %> consolidado. + <%= button_to '🔓 Reabrir veículo', + reabrir_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao), + method: :post, + params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel }, + data: { turbo_confirm: "Reabrir o veículo #{sel_unico} para edição?" }, + class: 'text-gray-400 hover:text-white border border-[#2a2a2a] hover:border-orange-500 px-3 py-2 rounded-lg text-xs min-h-[40px]' %> +
+ <% else %> +
+ <%# Habilita só quando o carro está 100% classificado; o JS liga/desliga + conforme a barra do escopo selecionado chega (ou não) a 100%. %> + <%= button_to '🔒 Consolidar este veículo', + fechar_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao), + method: :post, + params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel }, + disabled: !status_unico[:completo], + data: { 'validacao-target': 'consolidarBtn', + turbo_confirm: "Consolidar o veículo #{sel_unico}? Você poderá reabrir depois." }, + class: "font-bold px-4 py-2 rounded-lg text-xs min-h-[40px] #{status_unico[:completo] ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %> + <%= button_to '🗄️ Arquivar veículo', + arquivar_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao), + method: :post, + params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel }, + data: { turbo_confirm: "Arquivar o veículo #{sel_unico}? Ele sai da finalização (as entregas já classificadas continuam no pagamento)." }, + class: 'text-gray-400 hover:text-white border border-[#2a2a2a] hover:border-red-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %> + + <%= status_unico[:completo] ? 'Pronto para consolidar.' : 'Classifique 100% deste veículo para consolidar.' %> + +
+ <% end %> +
+ <% end %>
<% end %> @@ -172,6 +224,16 @@ <% end %> + <%# Veículo (Q3) — a qual carro este lançamento pertence (soma no balde dele) %> + + + <%# NF + observação (texto livre) %>
@@ -242,6 +304,16 @@
+ <%# Veículo (Q3) — a qual carro estes termos pertencem %> + + + <%# Stepper de quantidade %>
diff --git a/app/views/consolidacoes/show.html.erb b/app/views/consolidacoes/show.html.erb index 2ddc59d..bcf131d 100644 --- a/app/views/consolidacoes/show.html.erb +++ b/app/views/consolidacoes/show.html.erb @@ -91,6 +91,18 @@ <% end %>

<%= moeda(cm.valor_total) %>

+ <%# Breakdown por veículo (consolidados 🔒 / abertos). Informativo — o + pagamento continua sendo do motorista inteiro. %> + <% veiculos = @veiculos_por_motorista[cm.motorista_nome] %> + <% if veiculos.present? %> +
+ <% veiculos.each do |cv| %> + + <%= cv.fechado? ? '🔒' : '○' %> <%= cv.vehicle %> · <%= moeda(cv.valor_total) %> + + <% end %> +
+ <% end %> <% if cm.pago? %>

💰 Pago em <%= l cm.pago_em, format: :short %> diff --git a/app/views/consolidacoes/wizard.html.erb b/app/views/consolidacoes/wizard.html.erb index 6905c7f..0fba29b 100644 --- a/app/views/consolidacoes/wizard.html.erb +++ b/app/views/consolidacoes/wizard.html.erb @@ -187,6 +187,16 @@ · ➕ <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %> <% end %>

+ <% if m[:veiculos_total] > 0 %> +

+ + 🚗 <%= m[:veiculos_fechados] %>/<%= m[:veiculos_total] %> veículo<%= 's' if m[:veiculos_total] > 1 %> consolidado<%= 's' if m[:veiculos_fechados] != 1 %> + + <% if m[:veiculos_pendentes].any? %> + · pendente<%= 's' if m[:veiculos_pendentes].size > 1 %>: <%= m[:veiculos_pendentes].join(', ') %> + <% end %> +

+ <% end %>
@@ -248,23 +258,39 @@
<% end %> - <%# Finalizar — só habilita se tudo classificado %> - <% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %> -
-
-

Total da consolidação

-

<%= moeda(@consolidacao.valor_total) %>

+ <%# Finalizar — habilita quando tudo classificado. Se sobram veículos pendentes + mas já há veículos consolidados, oferece "arquivar pendentes e finalizar". %> + <% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %> + <% tem_consolidados = @motoristas.any? { |m| m[:veiculos_fechados] > 0 || m[:veiculos_prontos] > 0 } %> + <% tem_pendentes = @motoristas.any? { |m| m[:veiculos_pendentes].any? } %> +
+
+
+

Total da consolidação

+

<%= moeda(@consolidacao.valor_total) %>

+
+ <% if @consolidacao.rascunho? %> +
+ <% if !tudo_completo && tem_consolidados && tem_pendentes %> + <%= button_to '🔒 Arquivar pendentes e finalizar', + finalizar_parcial_consolidacao_path(@consolidacao), + method: :post, + data: { turbo_confirm: 'Arquivar TODOS os veículos ainda não classificados 100% e finalizar? Eles ficam de fora desta consolidação (você resolve depois). Os veículos consolidados são pagos normalmente.' }, + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3.5 rounded-lg min-h-[48px] flex items-center justify-center text-center' %> + <% end %> + <%= button_to 'Finalizar consolidação ✓', finalizar_consolidacao_path(@consolidacao), + method: :post, + disabled: !tudo_completo, + data: { turbo_confirm: 'Finalizar? Após finalizada, a consolidação não poderá ser editada por operadores.' }, + class: "font-bold px-6 py-3.5 rounded-lg min-h-[48px] transition-colors + #{tudo_completo ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %> +
+ <% end %>
- <% if @consolidacao.rascunho? %> - <%= button_to 'Finalizar consolidação ✓', finalizar_consolidacao_path(@consolidacao), - method: :post, - disabled: !tudo_completo, - data: { turbo_confirm: 'Finalizar? Após finalizada, a consolidação não poderá ser editada por operadores.' }, - class: "font-bold px-6 py-3.5 rounded-lg min-h-[48px] transition-colors - #{tudo_completo ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %> - <% end %>
<% unless tudo_completo %> -

⚠️ Classifique todas as entregas de todos os motoristas para poder finalizar.

+

+ ⚠️ Classifique todas as entregas de todos os motoristas para finalizar tudo<% if tem_consolidados && tem_pendentes %> — ou use “Arquivar pendentes e finalizar” para fechar só os veículos já consolidados.<% end %> +

<% end %>
diff --git a/config/routes.rb b/config/routes.rb index 7bc0127..299ba8a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,6 +41,7 @@ Rails.application.routes.draw do member do get :wizard # Passo 1 — selecionar motorista post :finalizar + post :finalizar_parcial # arquiva veículos pendentes e finaliza (só os consolidados) post :arquivar post :reativar # Restaura uma consolidação arquivada post :registrar_pagamento # Marca 1 motorista (ou todos) como pago @@ -61,6 +62,10 @@ Rails.application.routes.draw do post :classificar # toggle individual (JSON) post :classificar_em_massa # ações em massa (JSON) get :revisar # Passo 3 — revisão do motorista + post :fechar_veiculo # consolida (fecha) um veículo do motorista + post :reabrir_veiculo # reabre um veículo já consolidado + post :arquivar_veiculo # tira um veículo do gate (reversível) + post :desarquivar_veiculo # restaura um veículo arquivado get :buscar_nf # JSON — busca a NF na SimpleRoute (apontamento manual) post :apontar # cria apontamento manual a partir da NF post :apontar_manual # apontamento 100% manual (sem rastreio) diff --git a/db/migrate/20260703000001_add_vehicle_to_consolidacao_entregas.rb b/db/migrate/20260703000001_add_vehicle_to_consolidacao_entregas.rb new file mode 100644 index 0000000..ced73c5 --- /dev/null +++ b/db/migrate/20260703000001_add_vehicle_to_consolidacao_entregas.rb @@ -0,0 +1,38 @@ +# Snapshot do veículo em cada pilar da consolidação. O veículo vive só na tabela +# externa de rastreio (db_reem_simplerout_2026), acessível via tracking_id. Guardar +# uma cópia aqui permite fechar/consolidar POR VEÍCULO sem N+1 e mantém o histórico +# estável mesmo que a base externa mude depois. Lançamentos sintéticos (apontamento +# 100% manual "MAN-*" e entrega de termo "TERMO-*") não têm veículo na rastreio e +# ficam NULL — o agrupamento trata NULL como "(sem veículo)". +class AddVehicleToConsolidacaoEntregas < ActiveRecord::Migration[7.1] + def up + unless column_exists?(:consolidacao_entregas, :vehicle) + add_column :consolidacao_entregas, :vehicle, :string + add_index :consolidacao_entregas, %i[consolidacao_id motorista_nome vehicle], + name: 'idx_consolidacao_entrega_veiculo' + end + + # Backfill dos lançamentos já existentes a partir da rastreio. Só roda onde a + # tabela externa existe (dev/prod) — no banco de teste ela é isolada, então + # pulamos para não quebrar o db:migrate. + return unless connection.table_exists?('db_reem_simplerout_2026') + + say_with_time 'backfill vehicle em consolidacao_entregas' do + execute(<<~SQL.squish) + UPDATE consolidacao_entregas ce + SET vehicle = e.vehicle + FROM db_reem_simplerout_2026 e + WHERE e.tracking_id = ce.tracking_id + AND ce.vehicle IS NULL + SQL + end + end + + def down + if index_exists?(:consolidacao_entregas, %i[consolidacao_id motorista_nome vehicle], + name: 'idx_consolidacao_entrega_veiculo') + remove_index :consolidacao_entregas, name: 'idx_consolidacao_entrega_veiculo' + end + remove_column :consolidacao_entregas, :vehicle if column_exists?(:consolidacao_entregas, :vehicle) + end +end diff --git a/db/migrate/20260703000002_create_consolidacao_veiculos.rb b/db/migrate/20260703000002_create_consolidacao_veiculos.rb new file mode 100644 index 0000000..22d767f --- /dev/null +++ b/db/migrate/20260703000002_create_consolidacao_veiculos.rb @@ -0,0 +1,61 @@ +# Unidade de FECHAMENTO por veículo dentro de uma consolidação. Espelha o padrão de +# consolidacao_motoristas (arquivamento reversível + valor_total), mas o que ela +# controla é o estado "fechado/consolidado" de cada carro — o PAGAMENTO continua no +# motorista (consolidacao_motoristas). Assim dá para consolidar um veículo pronto +# sem esperar os outros. O bucket sem veículo (termo/apontamento manual legado) usa +# o sentinel '(sem veículo)' para que o índice único funcione (NULL quebraria). +class CreateConsolidacaoVeiculos < ActiveRecord::Migration[7.1] + SEM_VEICULO = '(sem veículo)' + + def up + unless table_exists?(:consolidacao_veiculos) + create_table :consolidacao_veiculos do |t| + t.references :consolidacao, null: false, foreign_key: { to_table: :consolidacoes } + t.string :motorista_nome, null: false + t.string :vehicle, null: false + t.decimal :valor_total, precision: 10, scale: 2, default: 0 + + t.datetime :fechado_em # NULL = aberto (ainda não consolidado) + t.integer :fechado_por + t.datetime :arquivado_em # NULL = ativo + t.integer :arquivado_por + + t.timestamps + end + + add_index :consolidacao_veiculos, %i[consolidacao_id motorista_nome vehicle], + unique: true, name: 'idx_consolidacao_veiculo_unique' + add_index :consolidacao_veiculos, :arquivado_em + end + + # Backfill: uma linha por (consolidação · motorista · veículo) já classificado, + # somando o valor (desconto = tipo 3 subtrai). Consolidações já finalizadas + # (status 1) nascem FECHADAS, herdando quem/quando finalizou — para não + # "reabrir" o que já foi pago. + say_with_time 'backfill consolidacao_veiculos a partir das entregas' do + execute(<<~SQL.squish) + INSERT INTO consolidacao_veiculos + (consolidacao_id, motorista_nome, vehicle, valor_total, + fechado_em, fechado_por, created_at, updated_at) + SELECT ce.consolidacao_id, + ce.motorista_nome, + COALESCE(NULLIF(ce.vehicle, ''), '#{SEM_VEICULO}') AS vehicle, + COALESCE(SUM(CASE WHEN ce.tipo = 3 + THEN -ce.valor_aplicado + ELSE ce.valor_aplicado END), 0) AS valor_total, + CASE WHEN c.status = 1 THEN c.finalizado_em END AS fechado_em, + CASE WHEN c.status = 1 THEN c.finalizado_por END AS fechado_por, + NOW(), NOW() + FROM consolidacao_entregas ce + JOIN consolidacoes c ON c.id = ce.consolidacao_id + GROUP BY ce.consolidacao_id, ce.motorista_nome, + COALESCE(NULLIF(ce.vehicle, ''), '#{SEM_VEICULO}'), + c.status, c.finalizado_em, c.finalizado_por + SQL + end + end + + def down + drop_table :consolidacao_veiculos if table_exists?(:consolidacao_veiculos) + end +end diff --git a/spec/factories.rb b/spec/factories.rb index e52dba6..c91a81d 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -39,6 +39,12 @@ FactoryBot.define do sequence(:motorista_nome) { |n| "Motorista #{n}" } end + factory :consolidacao_veiculo do + association :consolidacao + sequence(:motorista_nome) { |n| "Motorista #{n}" } + sequence(:vehicle) { |n| "GADE_#{format('%03d', n)}" } + end + factory :consolidacao_entrega do association :consolidacao sequence(:tracking_id) { |n| "TRK-#{n}" } diff --git a/spec/models/consolidacao_spec.rb b/spec/models/consolidacao_spec.rb index 74a8117..cb9a616 100644 --- a/spec/models/consolidacao_spec.rb +++ b/spec/models/consolidacao_spec.rb @@ -232,4 +232,108 @@ RSpec.describe Consolidacao, type: :model do expect(Consolidacao.arquivadas).to include(arquivada) end end + + describe '#sincronizar_veiculos! (via recalcular_motorista!)' do + let(:consolidacao) { create(:consolidacao) } + + before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') } + + it 'cria uma linha por veículo classificado, somando o valor do veículo' do + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + vehicle: 'GADE_1', tipo: :entrega_normal, valor_aplicado: 15) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + vehicle: 'GADE_1', tipo: :bonus, valor_aplicado: 10) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + vehicle: 'GADE_2', tipo: :entrega_normal, valor_aplicado: 20) + + consolidacao.recalcular_motorista!('A') + + veics = consolidacao.consolidacao_veiculos.where(motorista_nome: 'A').order(:vehicle) + expect(veics.map(&:vehicle)).to eq(%w[GADE_1 GADE_2]) + expect(veics.find_by(vehicle: 'GADE_1').valor_total).to eq(25) + expect(veics.find_by(vehicle: 'GADE_2').valor_total).to eq(20) + end + + it 'remove o veículo aberto que ficou sem pilares, mas preserva o fechado' do + e1 = create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + vehicle: 'GADE_1', valor_aplicado: 15) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + vehicle: 'GADE_2', valor_aplicado: 20) + consolidacao.recalcular_motorista!('A') + consolidacao.consolidacao_veiculos.find_by(vehicle: 'GADE_2').fechar!(create(:gerente)) + + e1.destroy + consolidacao.recalcular_motorista!('A') + + veics = consolidacao.consolidacao_veiculos.where(motorista_nome: 'A') + expect(veics.pluck(:vehicle)).to contain_exactly('GADE_2') # GADE_1 (aberto e vazio) removido + end + end + + describe 'gate: veículo arquivado libera a finalização' do + let(:consolidacao) { create(:consolidacao) } + + before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') } + + it 'desconta as elegíveis do veículo arquivado' do + allow(consolidacao).to receive(:entregas_elegiveis_count).with('A').and_return(3) + allow(consolidacao).to receive(:entregas_elegiveis_count_veiculo).with('A', 'GADE_2').and_return(1) + + # 2 classificadas (GADE_1). Falta 1 (GADE_2) => incompleto. + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + vehicle: 'GADE_1', tracking_id: 'T1') + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + vehicle: 'GADE_1', tracking_id: 'T2', tipo: :bonus) + expect(consolidacao.motorista_completo?('A')).to be false + + # Arquiva GADE_2 (o carro sem classificar): total cai p/ 2 => completo. + cv = consolidacao.consolidacao_veiculos.create!(motorista_nome: 'A', vehicle: 'GADE_2') + cv.arquivar!(create(:gerente)) + expect(consolidacao.motorista_completo?('A')).to be true + end + end + + describe '#arquivar_veiculos_pendentes!' do + let(:consolidacao) { create(:consolidacao) } + let(:gerente) { create(:gerente) } + + before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') } + + it 'arquiva só os veículos pendentes (com elegíveis e < 100%)' do + allow(consolidacao).to receive(:veiculos_do_motorista).with('A').and_return([ + { vehicle: 'GADE_1', eligible: 5, classificadas: 5, completo: true, fechado: true, arquivado: false }, + { vehicle: 'GADE_2', eligible: 3, classificadas: 1, completo: false, fechado: false, arquivado: false }, + { vehicle: '(sem veículo)', eligible: 0, classificadas: 0, completo: false, fechado: false, arquivado: false } + ]) + + arquivados = consolidacao.arquivar_veiculos_pendentes!(gerente) + + expect(arquivados).to eq(['A · GADE_2']) + expect(consolidacao.consolidacao_veiculos.find_by(motorista_nome: 'A', vehicle: 'GADE_2')).to be_arquivado + # não mexe no consolidado (GADE_1) nem no bucket sem elegíveis + expect(consolidacao.consolidacao_veiculos.where(vehicle: 'GADE_1')).not_to exist + expect(consolidacao.consolidacao_veiculos.where(vehicle: '(sem veículo)')).not_to exist + end + end + + describe '#finalizar! fecha os veículos ainda abertos' do + let(:consolidacao) { create(:consolidacao) } + let(:gerente) { create(:gerente) } + + before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') } + + it 'consolida os veículos abertos ao finalizar' do + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + vehicle: 'GADE_1', valor_aplicado: 15) + consolidacao.recalcular_motorista!('A') + cv = consolidacao.consolidacao_veiculos.find_by(vehicle: 'GADE_1') + expect(cv).not_to be_fechado + + allow(consolidacao).to receive(:todas_entregas_classificadas?).and_return(true) + consolidacao.finalizar!(gerente) + + expect(cv.reload).to be_fechado + expect(cv.fechado_por).to eq(gerente.id) + end + end end diff --git a/spec/models/consolidacao_veiculo_spec.rb b/spec/models/consolidacao_veiculo_spec.rb new file mode 100644 index 0000000..34ce07c --- /dev/null +++ b/spec/models/consolidacao_veiculo_spec.rb @@ -0,0 +1,74 @@ +require 'rails_helper' + +RSpec.describe ConsolidacaoVeiculo, type: :model do + let(:consolidacao) { create(:consolidacao) } + + describe 'validações' do + it 'exige motorista_nome e vehicle' do + expect(build(:consolidacao_veiculo, motorista_nome: nil)).not_to be_valid + expect(build(:consolidacao_veiculo, vehicle: nil)).not_to be_valid + end + + it 'não permite o mesmo veículo duas vezes para o mesmo motorista' do + create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1') + dup = build(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1') + expect(dup).not_to be_valid + end + end + + describe '#recalcular_valor (before_save)' do + before do + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao', + vehicle: 'GADE_1', tipo: :entrega_normal, valor_aplicado: 15) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao', + vehicle: 'GADE_1', tipo: :desconto, valor_aplicado: 5) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao', + vehicle: 'GADE_2', tipo: :entrega_normal, valor_aplicado: 99) + end + + it 'soma só os pilares do veículo e trata desconto como negativo' do + cv = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'Joao', vehicle: 'GADE_1') + expect(cv.valor_total).to eq(10) # 15 - 5 (GADE_2 fica de fora) + end + + it 'o bucket "(sem veículo)" casa pilares com vehicle NULL/vazio' do + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao', + vehicle: nil, tipo: :bonus, valor_aplicado: 7) + cv = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'Joao', + vehicle: ConsolidacaoVeiculo::SEM_VEICULO) + expect(cv.valor_total).to eq(7) + end + end + + describe 'fechamento e arquivamento' do + let(:gerente) { create(:gerente) } + + it 'fecha e reabre registrando quem/quando' do + cv = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1') + expect(cv).not_to be_fechado + + cv.fechar!(gerente) + cv.reload + expect(cv).to be_fechado + expect(cv.fechado_por).to eq(gerente.id) + expect(cv.fechado_em).to be_present + + cv.reabrir!(gerente) + expect(cv.reload).not_to be_fechado + end + + it 'arquiva e desarquiva, e os escopos separam os estados' do + aberto = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1') + fechado = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'B', vehicle: 'GADE_2') + arquivado = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'C', vehicle: 'GADE_3') + fechado.fechar!(gerente) + arquivado.arquivar!(gerente) + + expect(ConsolidacaoVeiculo.fechados).to include(fechado) + expect(ConsolidacaoVeiculo.fechados).not_to include(aberto) + expect(ConsolidacaoVeiculo.abertos).to include(aberto) + expect(ConsolidacaoVeiculo.ativos).not_to include(arquivado) + expect(ConsolidacaoVeiculo.arquivados).to contain_exactly(arquivado) + end + end +end diff --git a/spec/models/operacao_spec.rb b/spec/models/operacao_spec.rb new file mode 100644 index 0000000..a5ae59d --- /dev/null +++ b/spec/models/operacao_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +# label e grupo_de são só parsing do nome da tabela (sem tocar o banco). +RSpec.describe Operacao do + describe '.label (caixa alta, sem parecer o nome do banco)' do + it 'tira o prefixo e deixa em CAIXA ALTA' do + expect(Operacao.label('gade_entregas_ubs_norte_maio_2026')).to eq('UBS NORTE MAIO 2026') + expect(Operacao.label('gade_entregas_emad_fev_2026')).to eq('EMAD FEV 2026') + end + end + + describe '.grupo_de (agrupamento por mês)' do + it 'reconhece o mês por EXTENSO (maio) e agrupa no mês certo, item em caixa alta' do + g = Operacao.send(:grupo_de, 'gade_entregas_ubs_norte_maio_2026') + expect(g[:titulo]).to eq('Maio 2026') + expect(g[:item]).to eq('UBS NORTE') + end + + it 'continua reconhecendo a abreviação (fev)' do + g = Operacao.send(:grupo_de, 'gade_entregas_emad_fev_2026') + expect(g[:titulo]).to eq('Fevereiro 2026') + expect(g[:item]).to eq('EMAD') + end + + it 'cai em "Outras" quando não há mês reconhecível' do + g = Operacao.send(:grupo_de, 'gade_entregas_oeste_centro_remanescentes') + expect(g[:titulo]).to eq('Outras') + expect(g[:item]).to eq('OESTE CENTRO REMANESCENTES') + end + end +end