Adição do botão de entrega manual

This commit is contained in:
2026-06-25 11:35:41 -03:00
parent 022aac2616
commit 0c5b5bcbe6
22 changed files with 615 additions and 67 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@@ -794,7 +794,7 @@ docker-compose exec app bundle exec rails db:migrate
---
<details open>
<details>
<summary><strong>🆕 Atualização 25/06/2026 — Consolidação inclui entregas de insucesso (motorista foi ao local)</strong></summary>
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
</details>
---
<details open>
<summary><strong>🆕 Atualização 25/06/2026 — Apontamento 100% manual + Arquivar motorista individualmente</strong></summary>
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-<uuid>"`) 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
```
</details>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 })

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
]

View File

@@ -60,9 +60,10 @@
</thead>
<tbody class="divide-y divide-[#2a2a2a]">
<% @classificacoes.each do |c| %>
<% endereco_manual = [c.nf_manual.present? ? "NF #{c.nf_manual}" : nil, c.obs_manual.presence].compact.join(' · ') %>
<tr>
<td class="px-4 py-3 text-white text-xs"><%= c.entrega_original&.vehicle.presence || '—' %></td>
<td class="px-4 py-3 text-gray-300 text-xs"><%= c.entrega_original&.address.presence || '—' %></td>
<td class="px-4 py-3 text-white text-xs"><%= c.entrega_original&.vehicle.presence || (c.manual? ? 'manual' : '—') %></td>
<td class="px-4 py-3 text-gray-300 text-xs"><%= c.entrega_original&.address.presence || endereco_manual.presence || '—' %></td>
<td class="px-4 py-3">
<span class="<%= c.tipo_cor[:bg] %> <%= c.tipo_cor[:text] %> px-2 py-1 rounded text-xs font-bold">
<%= c.tipo_cor[:label] %>

View File

@@ -81,6 +81,89 @@
</div>
</div>
<%# 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? %>
<div data-controller="apontamento-manual"
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
class="mb-4">
<button type="button" data-action="apontamento-manual#abrir"
class="w-full md:w-auto inline-flex items-center justify-center gap-2 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-dashed border-purple-500/60 hover:border-purple-500 font-semibold px-4 py-3 rounded-lg min-h-[48px]">
Apontamento 100% manual
<span class="text-gray-500 text-xs font-normal hidden md:inline">(entrega fora da base de rastreio)</span>
</button>
<%# Modal %>
<div data-apontamento-manual-target="modal" data-action="click->apontamento-manual#fundo"
class="hidden fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-1">
<h2 class="text-xl font-bold text-white">Apontamento 100% manual</h2>
<button type="button" data-action="apontamento-manual#fechar"
class="text-gray-500 hover:text-white text-2xl leading-none">✕</button>
</div>
<p class="text-gray-400 text-sm mb-4">Lançe uma entrega à mão, sem buscar na base de rastreio. Use quando o motorista foi ao local mas a nota ficou registrada com outro motorista.</p>
<p data-apontamento-manual-target="erro" class="hidden bg-red-500/10 text-red-400 border border-red-500/30 rounded-lg px-3 py-2 text-sm mb-3"></p>
<%# Motorista (default = o motorista da tela; pode trocar) %>
<label class="block text-gray-400 text-sm mb-1">Motorista</label>
<input data-apontamento-manual-target="motorista" type="text" list="motoristas-consolidacao"
value="<%= @motorista %>" placeholder="Nome do motorista"
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none mb-3">
<datalist id="motoristas-consolidacao">
<% Array(@motoristas_consolidacao).each do |nome| %>
<option value="<%= nome %>"></option>
<% end %>
</datalist>
<%# NF + observação (texto livre) %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-gray-400 text-sm mb-1">NF <span class="text-gray-600">(opcional)</span></label>
<input data-apontamento-manual-target="nf" type="text" inputmode="numeric" placeholder="Número da nota"
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none">
</div>
<div>
<label class="block text-gray-400 text-sm mb-1">Observação / local <span class="text-gray-600">(opcional)</span></label>
<input data-apontamento-manual-target="obs" type="text" placeholder="Ex.: UBS Sacomã"
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none">
</div>
</div>
<%# Pilares (múltiplos) %>
<label class="block text-gray-400 text-sm mb-2">Classificar como <span class="text-gray-600">(pode marcar mais de uma)</span></label>
<div class="grid grid-cols-2 gap-2 mb-3">
<% [['entrega_normal', '🚚 Entrega Normal', Configuracao.preco_entrega],
['retirada', '📦 Retirada', Configuracao.preco_retirada],
['bonus', '⭐ Bônus', Configuracao.preco_bonus],
['desconto', '⚠️ Desconto', Configuracao.preco_desconto],
['extraordinaria', '✨ Entrega Extraordinária', Configuracao.preco_extraordinaria]].each do |tipo, label, preco| %>
<label class="flex items-center gap-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-3 py-3 cursor-pointer hover:border-purple-500 text-sm text-white">
<input type="checkbox" value="<%= tipo %>" data-apontamento-manual-target="tipoCheck"
<%= 'checked' if tipo == 'entrega_normal' %>
class="accent-purple-500">
<span><%= label %><br><span class="text-gray-500 text-xs"><%= moeda(preco) %></span></span>
</label>
<% end %>
</div>
<%# Valor customizado da Extraordinária (opcional) %>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">Valor da Extraordinária <span class="text-gray-600">(opcional — vazio usa o padrão)</span></label>
<input data-apontamento-manual-target="valorExtra" type="text" inputmode="decimal" placeholder="<%= moeda(Configuracao.preco_extraordinaria) %>"
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none">
</div>
<button type="button" data-action="apontamento-manual#adicionar"
class="w-full bg-green-600 hover:bg-green-500 text-white font-bold py-3.5 rounded-lg min-h-[48px]">
Adicionar apontamento ✓
</button>
</div>
</div>
</div>
<% end %>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<%# ── Lista de entregas (3 colunas) ─────────────────── %>

View File

@@ -160,11 +160,16 @@
<tbody class="divide-y divide-[#2a2a2a]">
<% @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 } %>
<tr>
<td class="px-4 py-3">
<p class="text-white font-bold">NF <%= entrega&.numero_nf || '—' %></p>
<p class="text-gray-400 text-xs"><%= motorista %><% if entrega&.vehicle.present? %> · <%= entrega.vehicle %><% end %></p>
<p class="text-white font-bold">NF <%= nf_exibida.presence || '—' %></p>
<p class="text-gray-400 text-xs">
<%= motorista %><% if entrega&.vehicle.present? %> · <%= entrega.vehicle %><% end %>
<% if entrega.nil? && obs_manual.present? %> · <%= obs_manual %><% end %>
</p>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
@@ -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]' %>
</td>
<% end %>

View File

@@ -96,46 +96,84 @@
<% end %>
<%# Lista de motoristas %>
<% pode_arquivar_motorista = !(@consolidacao.finalizada? && current_user.operador?) %>
<div class="space-y-3">
<% @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 %>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-full flex items-center justify-center text-xl font-black
<%= m[:completo] ? 'bg-green-600 text-white' : 'bg-orange-500 text-black' %>">
<%= 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 <form>/<button_to> dentro de <a>). %>
<div class="relative">
<%= 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 %>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-full flex items-center justify-center text-xl font-black
<%= m[:completo] ? 'bg-green-600 text-white' : 'bg-orange-500 text-black' %>">
<%= m[:completo] ? '✓' : m[:registro].motorista_nome.first.upcase %>
</div>
<div>
<p class="text-white font-bold text-lg"><%= m[:registro].motorista_nome %></p>
<p class="text-gray-400 text-sm">
<%= m[:total] %> entregas pagas no período
· <span class="<%= m[:completo] ? 'text-green-400' : 'text-orange-400' %>">
<%= m[:classificadas] %>/<%= m[:total] %> classificadas
</span>
<% if m[:apontamentos].positive? %>
· <span class="text-orange-400"> <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %></span>
<% end %>
</p>
</div>
</div>
<div>
<p class="text-white font-bold text-lg"><%= m[:registro].motorista_nome %></p>
<p class="text-gray-400 text-sm">
<%= m[:total] %> entregas pagas no período
· <span class="<%= m[:completo] ? 'text-green-400' : 'text-orange-400' %>">
<%= m[:classificadas] %>/<%= m[:total] %> classificadas
</span>
<% if m[:apontamentos].positive? %>
· <span class="text-orange-400"> <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %></span>
<% end %>
</p>
<div class="text-right <%= 'pr-10' if pode_arquivar_motorista %>">
<p class="text-orange-500 font-bold"><%= moeda(m[:registro].valor_total) %></p>
<span class="text-gray-600 text-2xl">→</span>
</div>
</div>
<div class="text-right">
<p class="text-orange-500 font-bold"><%= moeda(m[:registro].valor_total) %></p>
<span class="text-gray-600 text-2xl">→</span>
<div class="mt-3">
<% pct = m[:total].zero? ? 0 : (m[:classificadas] * 100 / m[:total]) %>
<div class="w-full bg-[#0a0a0a] rounded-full h-2">
<div class="h-2 rounded-full <%= m[:completo] ? 'bg-green-500' : 'bg-orange-500' %> transition-all"
style="width: <%= pct %>%"></div>
</div>
</div>
</div>
<div class="mt-3">
<% pct = m[:total].zero? ? 0 : (m[:classificadas] * 100 / m[:total]) %>
<div class="w-full bg-[#0a0a0a] rounded-full h-2">
<div class="h-2 rounded-full <%= m[:completo] ? 'bg-green-500' : 'bg-orange-500' %> transition-all"
style="width: <%= pct %>%"></div>
</div>
</div>
<% 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 %>
</div>
<% end %>
</div>
<%# Motoristas arquivados — restauráveis %>
<% if @arquivados.present? %>
<div class="mt-6">
<h2 class="text-gray-400 font-semibold text-sm mb-2">🗄️ Motoristas arquivados (<%= @arquivados.size %>)</h2>
<div class="space-y-2">
<% @arquivados.each do |cm| %>
<div class="flex items-center justify-between bg-[#141414] border border-[#2a2a2a] border-dashed rounded-xl px-4 py-3">
<div>
<p class="text-gray-300 font-semibold"><%= cm.motorista_nome %></p>
<p class="text-gray-600 text-xs">Arquivado · não conta no total</p>
</div>
<% 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 %>
</div>
<% end %>
</div>
</div>
<% end %>
<%# Finalizar — só habilita se tudo classificado %>
<% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %>
<div class="mt-8 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5 flex items-center justify-between">

View File

@@ -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

View File

@@ -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

View File

@@ -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-<uuid>"), 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

View File

@@ -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

View File

@@ -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)