Adição do botão de entrega manual
This commit is contained in:
BIN
Imagens para implantação /Imagem colada (17).png
Normal file
BIN
Imagens para implantação /Imagem colada (17).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 478 KiB |
BIN
Imagens para implantação /Imagem colada (18).png
Normal file
BIN
Imagens para implantação /Imagem colada (18).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
66
README.md
66
README.md
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
76
app/javascript/controllers/apontamento_manual_controller.js
Normal file
76
app/javascript/controllers/apontamento_manual_controller.js
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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] %>
|
||||
|
||||
@@ -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) ─────────────────── %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user