From 79c0f646daa89d2b6f0b8fb37490f9a77c52f4b8f2c1563e6bd77dd569288d87 Mon Sep 17 00:00:00 2001 From: victor Date: Fri, 19 Jun 2026 14:35:18 -0300 Subject: [PATCH] =?UTF-8?q?Implanta=C3=A7=C3=A3o=20da=20fun=C3=A7=C3=A3o?= =?UTF-8?q?=20d=20adicionar=20entregas=20por=20fora=20da=20opera=C3=A7?= =?UTF-8?q?=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consolidacao_entregas_controller.rb | 106 ++++++++++++++++ app/controllers/consolidacoes_controller.rb | 9 +- .../controllers/apontamento_controller.js | 118 ++++++++++++++++++ app/models/consolidacao.rb | 6 +- app/models/consolidacao_entrega.rb | 4 + app/models/entrega.rb | 7 ++ .../consolidacao_entregas/revisar.html.erb | 15 ++- app/views/consolidacoes/wizard.html.erb | 60 +++++++++ config/routes.rb | 3 + ...001_add_manual_to_consolidacao_entregas.rb | 11 ++ spec/models/consolidacao_spec.rb | 25 ++++ 11 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 app/javascript/controllers/apontamento_controller.js create mode 100644 db/migrate/20260619000001_add_manual_to_consolidacao_entregas.rb diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb index 4f49b1f..24ddcce 100644 --- a/app/controllers/consolidacao_entregas_controller.rb +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -164,6 +164,91 @@ class ConsolidacaoEntregasController < ApplicationController end end + # GET /consolidacoes/:consolidacao_id/consolidacao_entregas/buscar_nf?nf=123 + # APONTAMENTO MANUAL — busca a NF na SimpleRoute (sem filtro de período). + def buscar_nf + authorize @consolidacao, :update? + + nf = params[:nf].to_s.strip + return render(json: { ok: false, erro: 'Informe o número da nota.' }, status: :unprocessable_entity) if nf.blank? + + entregas = Entrega.da_conta_gade.por_nf(nf).order(:checkout) + + if entregas.empty? + render json: { ok: false, erro: "Nota #{nf} não encontrada na base de rastreio." } + else + render json: { ok: true, nf: nf, entregas: entregas.map { |e| entrega_json(e) } } + end + end + + # POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar + # Cria um apontamento manual a partir de um tracking_id encontrado pela NF. + def apontar + authorize @consolidacao, :update? + + tipo = params[:tipo].to_s + return render_apontamento_erro('Tipo inválido.') unless ConsolidacaoEntrega.tipos.key?(tipo) + + entrega = Entrega.da_conta_gade.find_by(tracking_id: params[:tracking_id]) + return render_apontamento_erro('Entrega não encontrada na base de rastreio.') unless entrega + + motorista = entrega.driver.to_s.strip + return render_apontamento_erro('Entrega sem motorista — não é possível apontar.') if motorista.blank? + + # Garante o motorista na consolidação (pode não estar, já que a NF veio por fora). + @consolidacao.consolidacao_motoristas.find_or_create_by!(motorista_nome: motorista) + + ce = @consolidacao.consolidacao_entregas + .find_or_initialize_by(tracking_id: entrega.tracking_id, tipo: tipo) + if ce.persisted? + return render_apontamento_erro("Essa nota já está classificada como #{ConsolidacaoEntrega::TIPO_CORES[tipo][:label]} para #{motorista}.") + end + + ce.assign_attributes( + motorista_nome: motorista, + valor_aplicado: valor_para_tipo(tipo), + manual: true, + created_by: current_user.id + ) + ce.save! + + recalcular_motorista(motorista) + auditar!(:editar, @consolidacao, + dados_novos: { apontamento_manual: entrega.numero_nf, tipo: tipo, motorista: motorista }) + + respond_to do |format| + format.json { render json: resumo_json(motorista).merge(motorista: motorista) } + format.html do + redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista), + notice: "Apontamento da nota #{entrega.numero_nf} adicionado para #{motorista}." + end + end + end + + # DELETE /consolidacoes/:consolidacao_id/consolidacao_entregas/remover_apontamento?id=1 + # Remove um apontamento manual. (Não passa pelo guard de elegibilidade, pois o + # tracking está fora do período — por isso não reusa o toggle classificar.) + def remover_apontamento + authorize @consolidacao, :update? + + ce = @consolidacao.consolidacao_entregas.apontamentos.find_by(id: params[:id]) + return render_apontamento_erro('Apontamento não encontrado.') unless ce + + motorista = ce.motorista_nome + ce.destroy + recalcular_motorista(motorista) + auditar!(:editar, @consolidacao, + dados_novos: { remover_apontamento_manual: ce.tracking_id, motorista: motorista }) + + respond_to do |format| + format.json { render json: resumo_json(motorista).merge(motorista: motorista) } + format.html do + redirect_to revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista), + notice: 'Apontamento removido.' + end + end + end + private def set_consolidacao @@ -200,6 +285,27 @@ class ConsolidacaoEntregasController < ApplicationController tracking_ids_elegiveis(motorista).include?(tracking_id) end + # Dados da entrega para o modal de apontamento (conferência antes de salvar). + def entrega_json(e) + { + tracking_id: e.tracking_id, + nf: e.numero_nf, + motorista: e.driver, + veiculo: e.vehicle, + local: e.local, + status: e.status, + pago: e.elegivel_pagamento?, + data: (e.checkout || e.planned_date)&.to_s + } + end + + def render_apontamento_erro(msg) + respond_to do |format| + format.json { render json: { ok: false, erro: msg }, status: :unprocessable_entity } + format.html { redirect_to wizard_consolidacao_path(@consolidacao), alert: msg } + end + end + def render_nao_elegivel respond_to do |format| format.json { render json: { ok: false, erro: 'Entrega não elegível para este motorista.' }, status: :unprocessable_entity } diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index 4eb907c..f22491a 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -114,11 +114,18 @@ class ConsolidacoesController < ApplicationController @motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome).map do |cm| total = @consolidacao.entregas_elegiveis_count(cm.motorista_nome) classificadas = @consolidacao.classificadas_count(cm.motorista_nome) + apontamentos = @consolidacao.consolidacao_entregas + .apontamentos + .where(motorista_nome: cm.motorista_nome) + .distinct.count(:tracking_id) { registro: cm, total: total, classificadas: classificadas, - completo: total.positive? && classificadas >= total + 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 } end end diff --git a/app/javascript/controllers/apontamento_controller.js b/app/javascript/controllers/apontamento_controller.js new file mode 100644 index 0000000..4e3176b --- /dev/null +++ b/app/javascript/controllers/apontamento_controller.js @@ -0,0 +1,118 @@ +// app/javascript/controllers/apontamento_controller.js +// Stimulus controller — Apontamento manual de entrega na consolidação. +// Busca a NF na base de rastreio (SimpleRoute), exibe os dados para conferência +// e cria o apontamento com o tipo (pilar) escolhido. + +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["modal", "nf", "erro", "resultados", "confirmar", "tipo"] + static values = { buscarUrl: String, apontarUrl: String } + + // ── Modal ────────────────────────────────────────────────── + abrir() { + this.reset() + this.modalTarget.classList.remove("hidden") + this.nfTarget.focus() + } + + fechar() { + this.modalTarget.classList.add("hidden") + } + + // Fecha ao clicar no fundo (fora do card) + fundo(event) { + if (event.target === this.modalTarget) this.fechar() + } + + reset() { + this.nfTarget.value = "" + this.erroTarget.classList.add("hidden") + this.resultadosTarget.innerHTML = "" + this.confirmarTarget.classList.add("hidden") + } + + // ── Busca da NF ──────────────────────────────────────────── + async buscar(event) { + event?.preventDefault() + const nf = this.nfTarget.value.trim() + if (!nf) { this.mostrarErro("Informe o número da nota."); return } + + this.erroTarget.classList.add("hidden") + this.resultadosTarget.innerHTML = `

Buscando…

` + this.confirmarTarget.classList.add("hidden") + + try { + const url = `${this.buscarUrlValue}?nf=${encodeURIComponent(nf)}` + const resp = await fetch(url, { headers: { "Accept": "application/json" } }) + const data = await resp.json() + + if (!data.ok) { + this.resultadosTarget.innerHTML = "" + this.mostrarErro(data.erro || "Nota não encontrada.") + return + } + this.renderResultados(data.entregas) + } catch (e) { + this.resultadosTarget.innerHTML = "" + this.mostrarErro("Erro ao buscar a nota. Tente novamente.") + } + } + + renderResultados(entregas) { + const cards = entregas.map((e, i) => { + const pago = e.pago + ? `paga` + : `${e.status || "pendente"}` + return ` + ` + }).join("") + + this.resultadosTarget.innerHTML = cards + this.confirmarTarget.classList.remove("hidden") + } + + // ── Criar apontamento ────────────────────────────────────── + async apontar(event) { + event?.preventDefault() + const selecionado = this.resultadosTarget.querySelector('input[name="apontamento_entrega"]:checked') + if (!selecionado) { this.mostrarErro("Selecione uma entrega."); return } + + const btn = event.currentTarget + btn.disabled = true + try { + const resp = await fetch(this.apontarUrlValue, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ tracking_id: selecionado.value, tipo: this.tipoTarget.value }) + }) + const data = await resp.json() + if (!resp.ok || data.ok === false) { + this.mostrarErro(data.erro || "Não foi possível adicionar o apontamento.") + return + } + // Recarrega para refletir motorista/valores atualizados. + window.location.reload() + } catch (e) { + this.mostrarErro("Erro ao adicionar o apontamento. Tente novamente.") + } finally { + btn.disabled = false + } + } + + mostrarErro(msg) { + this.erroTarget.textContent = msg + this.erroTarget.classList.remove("hidden") + } +} diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index 0f8a62e..6e773ab 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -152,8 +152,12 @@ class Consolidacao < ApplicationRecord # Entrega conta como classificada se tiver ≥1 pilar (distinct por tracking_id, # já que agora pode haver vários pilares por entrega). + # Apontamentos manuais (manual: true) NÃO entram aqui: eles vivem fora do + # conjunto elegível do período, então contá-los inflaria o progresso para + # além de 100% (e quebraria o gate classificadas >= elegíveis). def classificadas_count(motorista) - consolidacao_entregas.where(motorista_nome: motorista).distinct.count(:tracking_id) + consolidacao_entregas.where(motorista_nome: motorista, manual: false) + .distinct.count(:tracking_id) end private diff --git a/app/models/consolidacao_entrega.rb b/app/models/consolidacao_entrega.rb index fd98bfe..cc3e8a5 100644 --- a/app/models/consolidacao_entrega.rb +++ b/app/models/consolidacao_entrega.rb @@ -5,6 +5,10 @@ class ConsolidacaoEntrega < ApplicationRecord belongs_to :consolidacao + # Apontamentos manuais — entregas incluídas por fora do período/operação + # (ex.: nota que chegou depois). Buscadas pela NF direto na SimpleRoute. + scope :apontamentos, -> { where(manual: true) } + enum tipo: { entrega_normal: 0, retirada: 1, diff --git a/app/models/entrega.rb b/app/models/entrega.rb index ff42d66..abf47fd 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -39,6 +39,13 @@ class Entrega < ApplicationRecord where(driver: nome) } + # Busca pela NF (reference_id). A coluna é numérica, então comparamos como + # texto para aceitar a entrada do usuário de forma robusta. Usado no + # apontamento manual de entregas (NF que chegou fora do período). + scope :por_nf, ->(nf) { + where('reference_id::text = ?', nf.to_s.strip) + } + scope :da_veiculo, ->(vehicle) { where(vehicle: vehicle) } diff --git a/app/views/consolidacao_entregas/revisar.html.erb b/app/views/consolidacao_entregas/revisar.html.erb index 3fdf28c..65d5543 100644 --- a/app/views/consolidacao_entregas/revisar.html.erb +++ b/app/views/consolidacao_entregas/revisar.html.erb @@ -66,9 +66,22 @@ <%= c.tipo_cor[:label] %> + <% if c.manual? %> + ➕ Apontamento + <% end %> - <%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %> +
+ <%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %> + <% if c.manual? && !(@consolidacao.finalizada? && current_user.operador?) %> + <%= button_to '✕', + remover_apontamento_consolidacao_consolidacao_entregas_path(@consolidacao, id: c.id, motorista: @motorista), + method: :delete, + form: { data: { turbo_confirm: 'Remover este apontamento manual?' } }, + class: 'text-gray-500 hover:text-red-400 text-sm leading-none px-1', + title: 'Remover apontamento' %> + <% end %> +
<% end %> diff --git a/app/views/consolidacoes/wizard.html.erb b/app/views/consolidacoes/wizard.html.erb index 8cdf056..e94d9c5 100644 --- a/app/views/consolidacoes/wizard.html.erb +++ b/app/views/consolidacoes/wizard.html.erb @@ -31,6 +31,63 @@ + <%# Apontamento manual — adiciona entrega por fora (busca pela NF na rastreio) %> + <% unless @consolidacao.finalizada? && current_user.operador? %> +
+ + + <%# Modal %> + +
+ <% end %> + <%# Lista de motoristas %>
<% @motoristas.each do |m| %> @@ -50,6 +107,9 @@ · <%= m[:classificadas] %>/<%= m[:total] %> classificadas + <% if m[:apontamentos].positive? %> + · ➕ <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %> + <% end %>

diff --git a/config/routes.rb b/config/routes.rb index b5ede08..cef1b4f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,9 @@ 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 + get :buscar_nf # JSON — busca a NF na SimpleRoute (apontamento manual) + post :apontar # cria apontamento manual a partir da NF + delete :remover_apontamento # remove um apontamento manual (por id) end end end diff --git a/db/migrate/20260619000001_add_manual_to_consolidacao_entregas.rb b/db/migrate/20260619000001_add_manual_to_consolidacao_entregas.rb new file mode 100644 index 0000000..a817a43 --- /dev/null +++ b/db/migrate/20260619000001_add_manual_to_consolidacao_entregas.rb @@ -0,0 +1,11 @@ +# Marca apontamentos manuais — entregas adicionadas por fora do período/operação +# da consolidação (ex.: nota que chegou depois). Diferencia "apontamento extra" +# de "entrega elegível do período" para não inflar a contagem de progresso. +class AddManualToConsolidacaoEntregas < ActiveRecord::Migration[7.1] + def change + unless column_exists?(:consolidacao_entregas, :manual) + add_column :consolidacao_entregas, :manual, :boolean, null: false, default: false + add_index :consolidacao_entregas, :manual + end + end +end diff --git a/spec/models/consolidacao_spec.rb b/spec/models/consolidacao_spec.rb index aca583c..6369a8d 100644 --- a/spec/models/consolidacao_spec.rb +++ b/spec/models/consolidacao_spec.rb @@ -88,6 +88,31 @@ RSpec.describe Consolidacao, type: :model do end end + describe '#classificadas_count (ignora apontamentos manuais)' do + let(:consolidacao) { create(:consolidacao) } + + it 'conta entregas elegíveis e ignora as manuais (não infla o progresso)' do + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', tracking_id: 'T1') + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', tracking_id: 'T2') + # Apontamento manual — fora do conjunto elegível do período. + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + tracking_id: 'T3', manual: true) + + expect(consolidacao.classificadas_count('A')).to eq(2) + end + + it 'não ultrapassa o total elegível por causa de apontamentos (gate continua válido)' do + create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') + allow(consolidacao).to receive(:entregas_elegiveis_count).and_return(1) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', tracking_id: 'T1') + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + tracking_id: 'T2', manual: true) + + expect(consolidacao.classificadas_count('A')).to eq(1) + expect(consolidacao.todas_entregas_classificadas?).to be true + end + end + describe 'escopos de soft delete' do it 'separa ativas de arquivadas' do ativa = create(:consolidacao)