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 %>Busque a nota pela base de rastreio e adicione ao fechamento do motorista correspondente.
+ + + + <%# Busca da NF %> + + + <%# Resultados (preenchidos via JS) %> + + + <%# Confirmação — tipo + adicionar (oculto até a busca retornar) %> +