diff --git a/Imagens para implantação /Imagem colada (17).png b/Imagens para implantação /Imagem colada (17).png new file mode 100644 index 0000000..e630221 Binary files /dev/null and b/Imagens para implantação /Imagem colada (17).png differ diff --git a/Imagens para implantação /Imagem colada (18).png b/Imagens para implantação /Imagem colada (18).png new file mode 100644 index 0000000..d906245 Binary files /dev/null and b/Imagens para implantação /Imagem colada (18).png differ diff --git a/README.md b/README.md index f09d793..bab0009 100644 --- a/README.md +++ b/README.md @@ -794,7 +794,7 @@ docker-compose exec app bundle exec rails db:migrate --- -
+
🆕 Atualização 25/06/2026 — Consolidação inclui entregas de insucesso (motorista foi ao local) A consolidação só considerava entregas **concluídas com sucesso**. Como o motorista @@ -838,3 +838,67 @@ app/views/consolidacao_entregas/validar.html.erb # selo de status por entrega
+--- + +
+🆕 Atualização 25/06/2026 — Apontamento 100% manual + Arquivar motorista individualmente + +Dois recursos na consolidação: + +### 1. Apontamento 100% manual (tela de Validar — Passo 2) +Caso real: o motorista **A** foi até o local mas **não** entregou; no rastreio que alimenta o +banco, a NF ficou registrada sob o motorista **B** (que de fato entregou). Logo, a busca por NF +nunca encontra o A — mas o A também recebe, pois foi ao local. O apontamento manual antigo +(busca a NF na base e puxa `entrega.driver`) não serve. +- **Novo lançamento 100% manual**, sem consultar a base de rastreio, atribuível a **qualquer + motorista** (seletor com os motoristas da consolidação, ou digita um novo), com **NF (texto + livre)**, **observação/local** e **pilar(es)** (+ valor da Extraordinária). +- Botão **"➕ Apontamento 100% manual"** na tela de Validar. Soma ao valor do motorista, mas + **não** conta no gate `X/Y classificadas` (é `manual: true`, igual aos apontamentos por NF). +- A NF digitada aparece na Revisão (Passo 3), na tela da consolidação e no **Relatório PDF** do + motorista (fallback `nf_manual`/`obs_manual` quando não há `Entrega` para consultar). + +### 2. Arquivar motorista individualmente (reversível) +Numa consolidação em massa, dá para **arquivar** um motorista específico no Passo 1 (botão 🗄️ +por card) — ele **sai dos totais, do pagamento e do gate**, mas os dados ficam guardados. Seção +**"🗄️ Motoristas arquivados"** no fim do wizard com **"♻️ Restaurar"**. +- **Não** arquiva motorista **já pago** (estorne antes) — bloqueado com alerta. +- Útil para finalizar a consolidação ignorando um motorista que não deveria estar nela / sem + entregas, sem perder o registro. + +### ⚙️ Como funciona (técnico) +- **Migrations** (rodar `db:migrate`): + - `20260625000001_add_arquivamento_to_consolidacao_motoristas` — `arquivado_em` / `arquivado_por`. + - `20260625000002_add_manual_fields_to_consolidacao_entregas` — `nf_manual` / `obs_manual`. +- `Consolidacao#adicionar_apontamento_manual!(motorista:, tipos:, user:, nf:, obs:, valor_extra:)` + — gera `tracking_id` sintético (`"MAN-"`) compartilhado pelos pilares (agrupa como UMA + entrega); reusa `recalcular_motorista!`. Action `apontar_manual` + rota + Stimulus + `apontamento_manual_controller.js`. +- `ConsolidacaoMotorista`: scopes `ativos`/`arquivados`, `arquivar!`/`desarquivar!`, `arquivado?`. + Todo o "conjunto vigente" passou a usar **`.ativos`** (totais, pagamento, gate, notificações, + PDFs financeiros, painel do motorista e filtros de pagamento do dashboard) — o arquivado não + entra em nenhum agregado. Actions `arquivar_motorista`/`desarquivar_motorista` (autorização + `update?`; bloqueiam motorista já pago). + +### 📂 Principais arquivos +``` +db/migrate/20260625000001_add_arquivamento_to_consolidacao_motoristas.rb +db/migrate/20260625000002_add_manual_fields_to_consolidacao_entregas.rb +app/models/consolidacao.rb · consolidacao_motorista.rb +app/controllers/consolidacoes_controller.rb · consolidacao_entregas_controller.rb +app/controllers/dashboard_controller.rb · motorista/dashboard_controller.rb +app/javascript/controllers/apontamento_manual_controller.js (novo) +app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb +app/views/consolidacoes/wizard.html.erb · show.html.erb +app/services/notificacao_service.rb · pdf/relatorio_motorista_pdf.rb · pdf/relatorio_financeiro_consolidacao_pdf.rb +config/routes.rb +spec/models/consolidacao_spec.rb · consolidacao_motorista_spec.rb +``` + +```bash +docker-compose exec app bundle exec rails db:migrate +docker-compose exec app bundle exec rspec +``` + +
+ diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb index 1991c69..8b25153 100644 --- a/app/controllers/consolidacao_entregas_controller.rb +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -31,6 +31,9 @@ class ConsolidacaoEntregasController < ApplicationController @total_entregas = @consolidacao.entregas_elegiveis_count(@motorista) @classificadas = @consolidacao.classificadas_count(@motorista) + # Nomes dos motoristas ATIVOS — alimenta o seletor do apontamento 100% manual. + @motoristas_consolidacao = @consolidacao.consolidacao_motoristas.ativos.pluck(:motorista_nome) + # Filtro "apenas não classificadas" if params[:apenas_pendentes] == '1' @entregas = @entregas.reject { |e| @classificacoes.key?(e.tracking_id) } @@ -157,8 +160,8 @@ class ConsolidacaoEntregasController < ApplicationController @pagy, @classificacoes = pagy(todas.order(:created_at), limit: @por_pagina, page: 1) end - # Próximo motorista pendente (para botão "Próximo Motorista") - nomes_completos = @consolidacao.consolidacao_motoristas.map(&:motorista_nome) + # Próximo motorista pendente (para botão "Próximo Motorista") — só ativos + nomes_completos = @consolidacao.consolidacao_motoristas.ativos.map(&:motorista_nome) @proximo = nomes_completos.find do |nome| next false if nome == @motorista total = @consolidacao.entregas_elegiveis_count(nome) @@ -207,6 +210,36 @@ class ConsolidacaoEntregasController < ApplicationController render_apontamento_erro(e.message) end + # POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar_manual + # Apontamento 100% MANUAL — não consulta a base de rastreio. O admin informa o + # motorista (pode ser outro que não o da tela), a NF (texto livre), uma + # observação/local e os pilares. Usado quando o motorista foi ao local mas a + # NF ficou registrada com outro motorista no rastreio. + def apontar_manual + authorize @consolidacao, :update? + + motorista = @consolidacao.adicionar_apontamento_manual!( + motorista: params[:motorista].to_s.strip, + tipos: tipos_param, + nf: params[:nf], + obs: params[:obs], + valor_extra: valor_extraordinaria_param, + user: current_user + ) + auditar!(:editar, @consolidacao, + dados_novos: { apontamento_100_manual: params[:nf], motorista: motorista, tipos: tipos_param }) + + 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 manual adicionado para #{motorista}." + end + end + rescue ArgumentError => e + render_apontamento_erro(e.message) + end + # DELETE /consolidacoes/:consolidacao_id/consolidacao_entregas/remover_apontamento # Remove um apontamento manual — por ?id= (um pilar) ou por ?tracking_id=&motorista= # (todos os pilares daquela nota). Não passa pelo guard de elegibilidade, pois o @@ -246,9 +279,9 @@ class ConsolidacaoEntregasController < ApplicationController redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.' end - # Motorista realmente faz parte desta consolidação? + # Motorista realmente faz parte desta consolidação? (ignora arquivados) def motorista_na_consolidacao?(nome) - nome.present? && @consolidacao.consolidacao_motoristas.exists?(motorista_nome: nome) + nome.present? && @consolidacao.consolidacao_motoristas.ativos.exists?(motorista_nome: nome) end # tracking_ids atendidos/elegíveis para o motorista no período (respeita veículos). @@ -311,6 +344,17 @@ class ConsolidacaoEntregasController < ApplicationController valor.present? && valor >= 0 ? valor : Configuracao.preco_extraordinaria end + # Valor customizado da Extraordinária vindo do formulário (params[:valor]), + # aceitando vírgula decimal. Retorna nil se ausente/inválido — aí o model usa + # o preço padrão de Configurações. + def valor_extraordinaria_param + bruto = params[:valor].to_s.strip.tr(',', '.') + return nil if bruto.blank? + + valor = BigDecimal(bruto) rescue nil + valor if valor && valor >= 0 + end + def recalcular_motorista(nome) @consolidacao.recalcular_motorista!(nome) end diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index d1ebf24..c640bcd 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -5,6 +5,7 @@ class ConsolidacoesController < ApplicationController before_action :set_consolidacao, only: %i[show edit update destroy finalizar arquivar reativar wizard registrar_pagamento cancelar_pagamento + arquivar_motorista desarquivar_motorista preview_extrato gerar_pdf_relatorio gerar_pdf_extrato gerar_pdf_financeiro] @@ -151,7 +152,8 @@ class ConsolidacoesController < ApplicationController # GET /consolidacoes/:id — visão geral def show authorize @consolidacao - @motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome) + # A gestão de arquivamento de motorista fica no wizard; aqui listamos só os ativos. + @motoristas = @consolidacao.consolidacao_motoristas.ativos.order(:motorista_nome) # Apontamentos manuais agrupados por nota (tracking) p/ exibição e remoção. @apontamentos = @consolidacao.consolidacao_entregas.apontamentos .order(:motorista_nome, :tracking_id) @@ -162,7 +164,8 @@ class ConsolidacoesController < ApplicationController def wizard authorize @consolidacao, :update? - @motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome).map do |cm| + @arquivados = @consolidacao.consolidacao_motoristas.arquivados.order(:motorista_nome) + @motoristas = @consolidacao.consolidacao_motoristas.ativos.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 @@ -265,6 +268,37 @@ class ConsolidacoesController < ApplicationController redirect_to @consolidacao, notice: pagamento_notice(alvos.size, :estornado) end + # POST /consolidacoes/:id/arquivar_motorista — arquiva 1 motorista da + # consolidação (reversível). Ele sai dos totais/pagamento/gate, mas os dados + # ficam guardados e podem ser restaurados. Não arquiva motorista já pago. + def arquivar_motorista + authorize @consolidacao, :update? + cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id]) + + if cm.pago? + return redirect_to wizard_consolidacao_path(@consolidacao), + alert: "#{cm.motorista_nome} já está pago — estorne o pagamento antes de arquivar." + end + + cm.arquivar!(current_user) + @consolidacao.recalcular_motorista!(cm.motorista_nome) # tira o valor do total + auditar!(:editar, @consolidacao, dados_novos: { arquivar_motorista: cm.motorista_nome }) + redirect_to wizard_consolidacao_path(@consolidacao), + notice: "#{cm.motorista_nome} arquivado. Você pode restaurar quando quiser." + end + + # POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado. + def desarquivar_motorista + authorize @consolidacao, :update? + cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id]) + + cm.desarquivar!(current_user) + @consolidacao.recalcular_motorista!(cm.motorista_nome) # devolve o valor ao total + auditar!(:editar, @consolidacao, dados_novos: { desarquivar_motorista: cm.motorista_nome }) + redirect_to wizard_consolidacao_path(@consolidacao), + notice: "#{cm.motorista_nome} restaurado para a consolidação." + end + # ── FASE 7 — PDFs ──────────────────────────────────────────── # GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X @@ -358,11 +392,12 @@ class ConsolidacoesController < ApplicationController end # Um motorista específico (consolidacao_motorista_id) ou todos da consolidação. + # "Todos" cobre apenas os ATIVOS — motorista arquivado não recebe pagamento. def motoristas_alvo if params[:consolidacao_motorista_id].present? [@consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])] else - @consolidacao.consolidacao_motoristas.to_a + @consolidacao.consolidacao_motoristas.ativos.to_a end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 9b2d758..78bf621 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -146,7 +146,7 @@ class DashboardController < ApplicationController # PAGOS: filtrados pela DATA DO PAGAMENTO (pago_em) dentro do período do topo # — "quanto foi pago neste período". - pagos = ConsolidacaoMotorista + pagos = ConsolidacaoMotorista.ativos .joins(:consolidacao) .where(consolidacoes: { status: :finalizada, deleted_at: nil }) .where(pago_em: intervalo) @@ -159,7 +159,7 @@ class DashboardController < ApplicationController # PENDENTES: consolidações finalizadas cujo PERÍODO cruza o filtro, ainda sem # pagamento (pendente não tem data de pagamento para filtrar). finalizadas_ids = @consolidacoes_mes.where(status: :finalizada).select(:id) - pendentes = ConsolidacaoMotorista.where(consolidacao_id: finalizadas_ids).pendentes + pendentes = ConsolidacaoMotorista.ativos.where(consolidacao_id: finalizadas_ids).pendentes @pag_pend_qtd = pendentes.count @pag_pend_valor = pendentes.sum(:valor_total) @@ -179,13 +179,13 @@ class DashboardController < ApplicationController # algum pagamento feito dentro do período. Sem o 2º caso, pagar uma # consolidação de outro mês não mexia nos gráficos (só na tabela de pagamentos). ids_periodo = base.where('data_inicio <= ? AND data_fim >= ?', @periodo_fim, @periodo_inicio).ids - ids_pagos = ConsolidacaoMotorista.where(pago_em: intervalo) + ids_pagos = ConsolidacaoMotorista.ativos.where(pago_em: intervalo) .where(consolidacao_id: base.select(:id)) .distinct.pluck(:consolidacao_id) consols = Consolidacao.where(id: (ids_periodo + ids_pagos).uniq).to_a ids = consols.map(&:id) - cms = ConsolidacaoMotorista.where(consolidacao_id: ids) + cms = ConsolidacaoMotorista.ativos.where(consolidacao_id: ids) ces = ConsolidacaoEntrega.where(consolidacao_id: ids) # KPIs diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index 5389fa8..85f42ff 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -20,11 +20,11 @@ module Motorista # Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas @minhas_consolidacoes = Consolidacao.ativas .joins(:consolidacao_motoristas) - .where(consolidacao_motoristas: { motorista_nome: nome }) + .where(consolidacao_motoristas: { motorista_nome: nome, arquivado_em: nil }) .recentes .distinct - @valor_consolidado = ConsolidacaoMotorista + @valor_consolidado = ConsolidacaoMotorista.ativos .joins(:consolidacao) .where(motorista_nome: nome, consolidacoes: { status: :finalizada, deleted_at: nil }) diff --git a/app/javascript/controllers/apontamento_manual_controller.js b/app/javascript/controllers/apontamento_manual_controller.js new file mode 100644 index 0000000..36c3910 --- /dev/null +++ b/app/javascript/controllers/apontamento_manual_controller.js @@ -0,0 +1,76 @@ +// app/javascript/controllers/apontamento_manual_controller.js +// Stimulus — Apontamento 100% MANUAL (sem consultar a base de rastreio). +// O admin informa motorista, NF (texto livre), observação/local e os pilares. +// Usado quando o motorista foi ao local mas a NF ficou com outro motorista no +// rastreio (a busca por NF nunca o encontraria). + +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["modal", "motorista", "nf", "obs", "erro", "tipoCheck", "valorExtra"] + static values = { apontarManualUrl: String } + + abrir() { + this.erroTarget.classList.add("hidden") + this.modalTarget.classList.remove("hidden") + this.motoristaTarget.focus() + } + + fechar() { + this.modalTarget.classList.add("hidden") + } + + // Fecha ao clicar no fundo (fora do card) + fundo(event) { + if (event.target === this.modalTarget) this.fechar() + } + + async adicionar(event) { + event?.preventDefault() + + const motorista = this.motoristaTarget.value.trim() + if (!motorista) { this.mostrarErro("Informe o motorista."); return } + + const tipos = this.tipoCheckTargets.filter(c => c.checked).map(c => c.value) + if (!tipos.length) { this.mostrarErro("Selecione ao menos uma classificação."); return } + + // Valor customizado da Extraordinária (opcional) + const valor = this.hasValorExtraTarget ? this.valorExtraTarget.value.trim() : "" + + const btn = event.currentTarget + btn.disabled = true + try { + const resp = await fetch(this.apontarManualUrlValue, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ + motorista: motorista, + nf: this.nfTarget.value.trim(), + obs: this.obsTarget.value.trim(), + tipos: tipos, + valor: valor + }) + }) + 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 o 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 30cc246..f80dbbf 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -33,17 +33,17 @@ class Consolidacao < ApplicationRecord # ── Filtro por status de pagamento (derivado dos motoristas) ─ # pago_em NÃO-nulo conta como pago; COUNT(pago_em) ignora os NULL. scope :pagamento_pago, -> { - where(id: ConsolidacaoMotorista.group(:consolidacao_id) + where(id: ConsolidacaoMotorista.ativos.group(:consolidacao_id) .having('COUNT(*) > 0 AND COUNT(*) = COUNT(pago_em)') .select(:consolidacao_id)) } scope :pagamento_pendente, -> { - where(id: ConsolidacaoMotorista.group(:consolidacao_id) + where(id: ConsolidacaoMotorista.ativos.group(:consolidacao_id) .having('COUNT(pago_em) = 0') .select(:consolidacao_id)) } scope :pagamento_parcial, -> { - where(id: ConsolidacaoMotorista.group(:consolidacao_id) + where(id: ConsolidacaoMotorista.ativos.group(:consolidacao_id) .having('COUNT(pago_em) > 0 AND COUNT(pago_em) < COUNT(*)') .select(:consolidacao_id)) } @@ -91,21 +91,21 @@ class Consolidacao < ApplicationRecord # ── Pagamento (status derivado dos motoristas da consolidação) ── # :pendente (nenhum pago) · :pago (todos pagos) · :parcial (alguns). def status_pagamento - total = consolidacao_motoristas.count + total = consolidacao_motoristas.ativos.count return :pendente if total.zero? - pagos = consolidacao_motoristas.pagos.count + pagos = consolidacao_motoristas.ativos.pagos.count return :pendente if pagos.zero? pagos >= total ? :pago : :parcial end def valor_pago - consolidacao_motoristas.pagos.sum(:valor_total) + consolidacao_motoristas.ativos.pagos.sum(:valor_total) end def valor_pendente - consolidacao_motoristas.pendentes.sum(:valor_total) + consolidacao_motoristas.ativos.pendentes.sum(:valor_total) end def finalizar!(user) @@ -122,7 +122,7 @@ class Consolidacao < ApplicationRecord # classificadas. (Antes era uma soma agregada com >=, que mascarava motoristas # pendentes quando outro tinha entregas a mais.) def todas_entregas_classificadas? - consolidacao_motoristas.all? do |cm| + consolidacao_motoristas.ativos.all? do |cm| total = entregas_elegiveis_count(cm.motorista_nome) total.zero? || classificadas_count(cm.motorista_nome) >= total end @@ -134,7 +134,7 @@ class Consolidacao < ApplicationRecord end def total_entregas_estimadas - @total_entregas_estimadas ||= consolidacao_motoristas.sum do |cm| + @total_entregas_estimadas ||= consolidacao_motoristas.ativos.sum do |cm| entregas_elegiveis_count(cm.motorista_nome) end end @@ -201,6 +201,49 @@ class Consolidacao < ApplicationRecord motorista end + # ── Apontamento 100% manual ───────────────────────────────── + # Lança uma entrega à mão SEM consultar a base de rastreio. Caso de uso: um + # motorista foi até o local mas a NF ficou registrada com outro motorista no + # rastreio — então a busca por NF nunca o encontra, mas ele também recebe. + # 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) + 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? + + nome = motorista.to_s.strip + raise ArgumentError, 'Informe o motorista do apontamento.' if nome.blank? + + tracking = "MAN-#{SecureRandom.uuid}" # sintético: agrupa os pilares como UMA entrega + + transaction do + consolidacao_motoristas.find_or_create_by!(motorista_nome: nome) + + tipos_validos.each do |tipo| + valor = if tipo == 'extraordinaria' && valor_extra.present? + valor_extra + else + ConsolidacaoEntrega.valor_para(tipo) + end + consolidacao_entregas.create!( + tracking_id: tracking, + tipo: tipo, + motorista_nome: nome, + valor_aplicado: valor, + manual: true, + nf_manual: nf.presence, + obs_manual: obs.presence, + created_by: user&.id + ) + end + + recalcular_motorista!(nome) + end + + nome + end + # Recalcula o valor de um motorista (soma dos pilares, desconto subtrai) e o # total da consolidação. Fonte única usada pelo wizard e pelos apontamentos. def recalcular_motorista!(nome) @@ -211,7 +254,7 @@ class Consolidacao < ApplicationRecord e.desconto? ? -e.valor_aplicado : e.valor_aplicado end cm.update_column(:valor_total, total) - update_column(:valor_total, consolidacao_motoristas.sum(:valor_total)) + update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total)) end private @@ -222,6 +265,6 @@ class Consolidacao < ApplicationRecord end def recalcular_valor_total - self.valor_total = consolidacao_motoristas.sum(:valor_total) + self.valor_total = consolidacao_motoristas.ativos.sum(:valor_total) end end diff --git a/app/models/consolidacao_motorista.rb b/app/models/consolidacao_motorista.rb index a031146..fdfb51d 100644 --- a/app/models/consolidacao_motorista.rb +++ b/app/models/consolidacao_motorista.rb @@ -5,7 +5,8 @@ class ConsolidacaoMotorista < ApplicationRecord self.table_name = 'consolidacao_motoristas' belongs_to :consolidacao - belongs_to :pagador, class_name: 'User', foreign_key: :pago_por, optional: true + belongs_to :pagador, class_name: 'User', foreign_key: :pago_por, optional: true + belongs_to :arquivador, class_name: 'User', foreign_key: :arquivado_por, optional: true has_many :consolidacao_entregas, ->(cm) { where(motorista_nome: cm.motorista_nome) }, foreign_key: :consolidacao_id, @@ -20,6 +21,27 @@ class ConsolidacaoMotorista < ApplicationRecord scope :pagos, -> { where.not(pago_em: nil) } scope :pendentes, -> { where(pago_em: nil) } + # ── Arquivamento (reversível) ─────────────────────────────── + # Um motorista arquivado sai dos totais/pagamento/gate da consolidação, mas + # os dados são mantidos e podem ser restaurados. + scope :ativos, -> { where(arquivado_em: nil) } + scope :arquivados, -> { where.not(arquivado_em: nil) } + + def arquivado? + arquivado_em.present? + end + + # update_columns: não dispara o before_save :recalcular_valor (que recalcula o + # valor do PRÓPRIO motorista). O total da CONSOLIDAÇÃO é recalculado pelo + # controller, que só soma os motoristas ativos. + 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 + def pago? pago_em.present? end diff --git a/app/services/notificacao_service.rb b/app/services/notificacao_service.rb index 458434b..53fee92 100644 --- a/app/services/notificacao_service.rb +++ b/app/services/notificacao_service.rb @@ -19,7 +19,7 @@ class NotificacaoService end def notificar_todos - @consolidacao.consolidacao_motoristas.each do |cm| + @consolidacao.consolidacao_motoristas.ativos.each do |cm| user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase) next unless user diff --git a/app/services/pdf/relatorio_financeiro_consolidacao_pdf.rb b/app/services/pdf/relatorio_financeiro_consolidacao_pdf.rb index 42a361a..60a8f47 100644 --- a/app/services/pdf/relatorio_financeiro_consolidacao_pdf.rb +++ b/app/services/pdf/relatorio_financeiro_consolidacao_pdf.rb @@ -11,7 +11,7 @@ module Pdf def initialize(consolidacao:) super() @consolidacao = consolidacao - @motoristas = consolidacao.consolidacao_motoristas.order(:motorista_nome) + @motoristas = consolidacao.consolidacao_motoristas.ativos.order(:motorista_nome) @entregas = consolidacao.consolidacao_entregas end diff --git a/app/services/pdf/relatorio_motorista_pdf.rb b/app/services/pdf/relatorio_motorista_pdf.rb index 79fb7c1..9708d32 100644 --- a/app/services/pdf/relatorio_motorista_pdf.rb +++ b/app/services/pdf/relatorio_motorista_pdf.rb @@ -34,9 +34,9 @@ module Pdf original = e.entrega_original linhas << [ (i + 1).to_s, - original&.vehicle.presence || '—', - original ? "NF #{original.numero_nf}" : '—', - original ? original.address.to_s : '—', + original&.vehicle.presence || (e.manual? ? 'manual' : '—'), + original ? "NF #{original.numero_nf}" : (e.nf_manual.present? ? "NF #{e.nf_manual}" : '—'), + original ? original.address.to_s : (e.obs_manual.presence || '—'), e.tipo_cor[:label], (e.desconto? ? '-' : '') + moeda(e.valor_aplicado) ] diff --git a/app/views/consolidacao_entregas/revisar.html.erb b/app/views/consolidacao_entregas/revisar.html.erb index c5707e4..52bd067 100644 --- a/app/views/consolidacao_entregas/revisar.html.erb +++ b/app/views/consolidacao_entregas/revisar.html.erb @@ -60,9 +60,10 @@ <% @classificacoes.each do |c| %> + <% endereco_manual = [c.nf_manual.present? ? "NF #{c.nf_manual}" : nil, c.obs_manual.presence].compact.join(' · ') %> - <%= c.entrega_original&.vehicle.presence || '—' %> - <%= c.entrega_original&.address.presence || '—' %> + <%= c.entrega_original&.vehicle.presence || (c.manual? ? 'manual' : '—') %> + <%= c.entrega_original&.address.presence || endereco_manual.presence || '—' %> <%= c.tipo_cor[:label] %> diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb index c70d296..ddaa9a4 100644 --- a/app/views/consolidacao_entregas/validar.html.erb +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -81,6 +81,89 @@ + <%# Apontamento 100% manual — entrega que NÃO está na base de rastreio + (ex.: motorista foi ao local mas a NF ficou com outro no rastreio). %> + <% unless @consolidacao.finalizada? && current_user.operador? %> +
+ + + <%# Modal %> + +
+ <% end %> +
<%# ── Lista de entregas (3 colunas) ─────────────────── %> diff --git a/app/views/consolidacoes/show.html.erb b/app/views/consolidacoes/show.html.erb index c9fa4b4..8dcf34f 100644 --- a/app/views/consolidacoes/show.html.erb +++ b/app/views/consolidacoes/show.html.erb @@ -160,11 +160,16 @@ <% @apontamentos.each do |(motorista, _tracking), pilares| %> <% entrega = pilares.first.entrega_original %> + <% nf_exibida = entrega&.numero_nf || pilares.first.nf_manual %> + <% obs_manual = pilares.first.obs_manual %> <% total = pilares.sum { |p| p.desconto? ? -p.valor_aplicado : p.valor_aplicado } %> -

NF <%= entrega&.numero_nf || '—' %>

-

<%= motorista %><% if entrega&.vehicle.present? %> · <%= entrega.vehicle %><% end %>

+

NF <%= nf_exibida.presence || '—' %>

+

+ <%= motorista %><% if entrega&.vehicle.present? %> · <%= entrega.vehicle %><% end %> + <% if entrega.nil? && obs_manual.present? %> · <%= obs_manual %><% end %> +

@@ -179,7 +184,7 @@ <%= button_to '✕ Remover', remover_apontamento_consolidacao_consolidacao_entregas_path(@consolidacao, tracking_id: pilares.first.tracking_id, motorista: motorista), method: :delete, - form: { data: { turbo_confirm: "Remover o apontamento da NF #{entrega&.numero_nf} de #{motorista}?" } }, + form: { data: { turbo_confirm: "Remover o apontamento da NF #{nf_exibida.presence || '—'} de #{motorista}?" } }, class: 'text-gray-400 hover:text-red-400 border border-[#2a2a2a] hover:border-red-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %> <% end %> diff --git a/app/views/consolidacoes/wizard.html.erb b/app/views/consolidacoes/wizard.html.erb index 593baa2..a0c5231 100644 --- a/app/views/consolidacoes/wizard.html.erb +++ b/app/views/consolidacoes/wizard.html.erb @@ -96,46 +96,84 @@ <% end %> <%# Lista de motoristas %> + <% pode_arquivar_motorista = !(@consolidacao.finalizada? && current_user.operador?) %>
<% @motoristas.each do |m| %> - <%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: m[:registro].motorista_nome), - class: "block bg-[#1a1a1a] border rounded-xl p-5 transition-colors - #{m[:completo] ? 'border-green-600 hover:border-green-500' : 'border-[#2a2a2a] hover:border-orange-500'}" do %> -
-
-
- <%= m[:completo] ? '✓' : m[:registro].motorista_nome.first.upcase %> + <%# Container relativo: o card é um link; o botão de arquivar fica FORA da + âncora (não se pode aninhar
/ dentro de ). %> +
+ <%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: m[:registro].motorista_nome), + class: "block bg-[#1a1a1a] border rounded-xl p-5 transition-colors + #{m[:completo] ? 'border-green-600 hover:border-green-500' : 'border-[#2a2a2a] hover:border-orange-500'}" do %> +
+
+
+ <%= m[:completo] ? '✓' : m[:registro].motorista_nome.first.upcase %> +
+
+

<%= m[:registro].motorista_nome %>

+

+ <%= m[:total] %> entregas pagas no período + · + <%= m[:classificadas] %>/<%= m[:total] %> classificadas + + <% if m[:apontamentos].positive? %> + · ➕ <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %> + <% end %> +

+
-
-

<%= m[:registro].motorista_nome %>

-

- <%= m[:total] %> entregas pagas no período - · - <%= m[:classificadas] %>/<%= m[:total] %> classificadas - - <% if m[:apontamentos].positive? %> - · ➕ <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %> - <% end %> -

+
+

<%= moeda(m[:registro].valor_total) %>

+
-
-

<%= moeda(m[:registro].valor_total) %>

- +
+ <% pct = m[:total].zero? ? 0 : (m[:classificadas] * 100 / m[:total]) %> +
+
+
-
-
- <% pct = m[:total].zero? ? 0 : (m[:classificadas] * 100 / m[:total]) %> -
-
-
-
- <% end %> + <% end %> + + <% if pode_arquivar_motorista %> + <%= button_to '🗄️', + arquivar_motorista_consolidacao_path(@consolidacao, consolidacao_motorista_id: m[:registro].id), + method: :post, + form: { data: { turbo_confirm: "Arquivar #{m[:registro].motorista_nome} desta consolidação? Você poderá restaurar depois." }, + class: 'absolute top-3 right-3' }, + class: 'text-gray-500 hover:text-red-400 border border-[#2a2a2a] hover:border-red-500/50 rounded-lg w-9 h-9 flex items-center justify-center', + title: "Arquivar #{m[:registro].motorista_nome}" %> + <% end %> +
<% end %>
+ <%# Motoristas arquivados — restauráveis %> + <% if @arquivados.present? %> +
+

🗄️ Motoristas arquivados (<%= @arquivados.size %>)

+
+ <% @arquivados.each do |cm| %> +
+
+

<%= cm.motorista_nome %>

+

Arquivado · não conta no total

+
+ <% if pode_arquivar_motorista %> + <%= button_to '♻️ Restaurar', + desarquivar_motorista_consolidacao_path(@consolidacao, consolidacao_motorista_id: cm.id), + method: :post, + class: 'text-green-400 hover:text-green-300 border border-[#2a2a2a] hover:border-green-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %> + <% end %> +
+ <% end %> +
+
+ <% end %> + <%# Finalizar — só habilita se tudo classificado %> <% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %>
diff --git a/config/routes.rb b/config/routes.rb index 79c2e73..e3ad29c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,6 +42,8 @@ Rails.application.routes.draw do post :reativar # Restaura uma consolidação arquivada post :registrar_pagamento # Marca 1 motorista (ou todos) como pago post :cancelar_pagamento # Estorna o pagamento de 1 motorista (ou todos) + post :arquivar_motorista # Arquiva 1 motorista da consolidação (reversível) + post :desarquivar_motorista # Restaura um motorista arquivado get :preview_extrato get :gerar_pdf_relatorio get :gerar_pdf_extrato @@ -56,6 +58,7 @@ Rails.application.routes.draw do 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 + post :apontar_manual # apontamento 100% manual (sem rastreio) delete :remover_apontamento # remove um apontamento manual (por id) end end diff --git a/db/migrate/20260625000001_add_arquivamento_to_consolidacao_motoristas.rb b/db/migrate/20260625000001_add_arquivamento_to_consolidacao_motoristas.rb new file mode 100644 index 0000000..6846305 --- /dev/null +++ b/db/migrate/20260625000001_add_arquivamento_to_consolidacao_motoristas.rb @@ -0,0 +1,13 @@ +# Arquivamento individual de motorista numa consolidação (reversível). +# Espelha o padrão de arquivamento da própria Consolidacao (arquivado_em/por): +# um motorista arquivado sai dos totais/pagamento/gate da consolidação, mas os +# dados são mantidos e podem ser restaurados. +class AddArquivamentoToConsolidacaoMotoristas < ActiveRecord::Migration[7.1] + def change + unless column_exists?(:consolidacao_motoristas, :arquivado_em) + add_column :consolidacao_motoristas, :arquivado_em, :datetime + add_column :consolidacao_motoristas, :arquivado_por, :integer + add_index :consolidacao_motoristas, :arquivado_em + end + end +end diff --git a/db/migrate/20260625000002_add_manual_fields_to_consolidacao_entregas.rb b/db/migrate/20260625000002_add_manual_fields_to_consolidacao_entregas.rb new file mode 100644 index 0000000..fa346e4 --- /dev/null +++ b/db/migrate/20260625000002_add_manual_fields_to_consolidacao_entregas.rb @@ -0,0 +1,13 @@ +# Campos para o apontamento 100% manual — entrega lançada à mão, sem registro na +# base de rastreio (ex.: motorista que foi ao local mas a NF ficou com outro +# motorista no rastreio). Como não existe uma Entrega para consultar, guardamos +# a NF digitada e uma observação/local aqui. O tracking_id desses lançamentos é +# sintético ("MAN-"), então entrega_original retorna nil. +class AddManualFieldsToConsolidacaoEntregas < ActiveRecord::Migration[7.1] + def change + unless column_exists?(:consolidacao_entregas, :nf_manual) + add_column :consolidacao_entregas, :nf_manual, :string + add_column :consolidacao_entregas, :obs_manual, :string + end + end +end diff --git a/spec/models/consolidacao_motorista_spec.rb b/spec/models/consolidacao_motorista_spec.rb index c4b145b..a53216b 100644 --- a/spec/models/consolidacao_motorista_spec.rb +++ b/spec/models/consolidacao_motorista_spec.rb @@ -46,4 +46,32 @@ RSpec.describe ConsolidacaoMotorista, type: :model do expect(cm.valor_total).to eq(20) end end + + describe 'arquivamento (reversível)' do + let(:consolidacao) { create(:consolidacao) } + let(:gerente) { create(:gerente) } + let(:cm) { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'Joao') } + + it 'arquivar!/desarquivar! alternam arquivado? e registram quem arquivou' do + expect(cm.arquivado?).to be false + cm.arquivar!(gerente) + expect(cm.reload.arquivado?).to be true + expect(cm.arquivado_por).to eq(gerente.id) + expect(cm.arquivado_em).to be_present + + cm.desarquivar!(gerente) + expect(cm.reload.arquivado?).to be false + expect(cm.arquivado_em).to be_nil + end + + it 'scopes ativos/arquivados separam corretamente' do + ativo = create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'Ativo') + arquivado = create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'Arquivado') + arquivado.arquivar!(gerente) + + expect(ConsolidacaoMotorista.ativos).to include(ativo) + expect(ConsolidacaoMotorista.ativos).not_to include(arquivado) + expect(ConsolidacaoMotorista.arquivados).to contain_exactly(arquivado) + end + end end diff --git a/spec/models/consolidacao_spec.rb b/spec/models/consolidacao_spec.rb index accc1fc..74a8117 100644 --- a/spec/models/consolidacao_spec.rb +++ b/spec/models/consolidacao_spec.rb @@ -141,6 +141,86 @@ RSpec.describe Consolidacao, type: :model do end end + describe '#adicionar_apontamento_manual! (100% manual, sem rastreio)' do + let(:consolidacao) { create(:consolidacao) } + let(:user) { create(:gerente) } + + before { allow(ConsolidacaoEntrega).to receive(:valor_para).and_return(15.0) } + + it 'cria pilares manuais com nf/obs, inclui o motorista e soma o valor' do + consolidacao.adicionar_apontamento_manual!( + motorista: 'Motorista A', tipos: %w[entrega_normal bonus], + nf: '12345', obs: 'UBS Sacomã', user: user + ) + + cm = consolidacao.consolidacao_motoristas.find_by(motorista_nome: 'Motorista A') + expect(cm).to be_present + + pilares = consolidacao.consolidacao_entregas.where(motorista_nome: 'Motorista A') + expect(pilares.count).to eq(2) + expect(pilares.all?(&:manual?)).to be true + expect(pilares.map(&:nf_manual).uniq).to eq(['12345']) + expect(pilares.map(&:obs_manual).uniq).to eq(['UBS Sacomã']) + # Mesmo tracking_id sintético agrupa os pilares como UMA entrega. + expect(pilares.map(&:tracking_id).uniq.size).to eq(1) + expect(cm.reload.valor_total).to eq(30.0) + end + + it 'não conta no gate (manual) mas exige motorista e ao menos um tipo' do + allow(consolidacao).to receive(:entregas_elegiveis_count).and_return(0) + consolidacao.adicionar_apontamento_manual!(motorista: 'Motorista A', tipos: %w[entrega_normal], user: user) + expect(consolidacao.classificadas_count('Motorista A')).to eq(0) + + expect { consolidacao.adicionar_apontamento_manual!(motorista: '', tipos: %w[entrega_normal], user: user) } + .to raise_error(ArgumentError) + expect { consolidacao.adicionar_apontamento_manual!(motorista: 'X', tipos: [], user: user) } + .to raise_error(ArgumentError) + end + + it 'usa valor_extra para a Extraordinária quando informado' do + consolidacao.adicionar_apontamento_manual!( + motorista: 'B', tipos: %w[extraordinaria], valor_extra: 99.0, user: user + ) + pilar = consolidacao.consolidacao_entregas.find_by(motorista_nome: 'B', tipo: :extraordinaria) + expect(pilar.valor_aplicado).to eq(99.0) + end + end + + describe 'arquivamento de motorista (reversível)' do + let(:consolidacao) { create(:consolidacao) } + let(:gerente) { create(:gerente) } + + before do + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A', + tipo: :entrega_normal, valor_aplicado: 20) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'B', + tipo: :entrega_normal, valor_aplicado: 30) + @cm_a = create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') + @cm_b = create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'B') + end + + it 'tira o motorista arquivado do total e do gate, e restaura ao desarquivar' do + consolidacao.update_column(:valor_total, consolidacao.consolidacao_motoristas.ativos.sum(:valor_total)) + expect(consolidacao.reload.valor_total).to eq(50) + + @cm_a.arquivar!(gerente) + consolidacao.recalcular_motorista!('A') + expect(@cm_a.reload).to be_arquivado + expect(consolidacao.reload.valor_total).to eq(30) # só B conta + expect(ConsolidacaoMotorista.ativos).to contain_exactly(@cm_b) + expect(ConsolidacaoMotorista.arquivados).to contain_exactly(@cm_a) + + # Gate ignora o motorista arquivado (A estava sem classificação completa). + allow(consolidacao).to receive(:entregas_elegiveis_count).and_return(0) + allow(consolidacao).to receive(:entregas_elegiveis_count).with('B').and_return(1) + expect(consolidacao.todas_entregas_classificadas?).to be true + + @cm_a.desarquivar!(gerente) + consolidacao.recalcular_motorista!('A') + expect(consolidacao.reload.valor_total).to eq(50) + end + end + describe 'escopos de soft delete' do it 'separa ativas de arquivadas' do ativa = create(:consolidacao)