436 lines
19 KiB
Ruby
436 lines
19 KiB
Ruby
# app/models/consolidacao.rb
|
||
class Consolidacao < ApplicationRecord
|
||
self.table_name = 'consolidacoes'
|
||
|
||
belongs_to :criador, class_name: 'User', foreign_key: :created_by
|
||
belongs_to :finalizador, class_name: 'User', foreign_key: :finalizado_por, optional: true
|
||
belongs_to :arquivador, class_name: 'User', foreign_key: :arquivado_por, optional: true
|
||
|
||
has_many :consolidacao_motoristas, dependent: :destroy
|
||
has_many :consolidacao_entregas, dependent: :destroy
|
||
has_many :consolidacao_veiculos, dependent: :destroy
|
||
|
||
# Bucket dos lançamentos sem veículo (termo/apontamento manual legado). Mesmo
|
||
# sentinel do controller e de ConsolidacaoVeiculo.
|
||
SEM_VEICULO = ConsolidacaoVeiculo::SEM_VEICULO
|
||
|
||
# ── Enums ───────────────────────────────────────────────────
|
||
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 }
|
||
|
||
# ── Soft delete ─────────────────────────────────────────────
|
||
scope :ativas, -> { where(deleted_at: nil) }
|
||
scope :arquivadas, -> { where.not(deleted_at: nil) }
|
||
|
||
# ── Validações ──────────────────────────────────────────────
|
||
validates :nome, presence: true
|
||
validates :data_inicio, presence: true
|
||
validates :data_fim, presence: true
|
||
validates :created_by, presence: true
|
||
validate :periodo_valido
|
||
|
||
# ── Callbacks ───────────────────────────────────────────────
|
||
before_save :recalcular_valor_total
|
||
|
||
# ── Escopos ─────────────────────────────────────────────────
|
||
scope :recentes, -> { order(created_at: :desc) }
|
||
# ⚠️ `no_periodo` exige a consolidação INTEIRA dentro da faixa. Não é o recorte
|
||
# das telas (elas usam `cruzando_periodo`, abaixo) — um fechamento de 01/08 a
|
||
# 31/08 sumia ao filtrar 01/08–26/08. Só use se o "caber dentro" for mesmo o
|
||
# que se quer.
|
||
scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) }
|
||
|
||
# Consolidações que CRUZAM o período (não precisam caber inteiras dentro dele).
|
||
# É o recorte do dashboard financeiro (DashboardController#carregar_dados_
|
||
# financeiro) e, por isso, o de qualquer tela que precise bater com ele: um
|
||
# fechamento de 01/08 a 31/08 tem que aparecer ao filtrar 01/08–26/08.
|
||
scope :cruzando_periodo, ->(i, f) { where('data_inicio <= ? AND data_fim >= ?', f, i) }
|
||
|
||
# ── 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.ativos.group(:consolidacao_id)
|
||
.having('COUNT(*) > 0 AND COUNT(*) = COUNT(pago_em)')
|
||
.select(:consolidacao_id))
|
||
}
|
||
scope :pagamento_pendente, -> {
|
||
where(id: ConsolidacaoMotorista.ativos.group(:consolidacao_id)
|
||
.having('COUNT(pago_em) = 0')
|
||
.select(:consolidacao_id))
|
||
}
|
||
scope :pagamento_parcial, -> {
|
||
where(id: ConsolidacaoMotorista.ativos.group(:consolidacao_id)
|
||
.having('COUNT(pago_em) > 0 AND COUNT(pago_em) < COUNT(*)')
|
||
.select(:consolidacao_id))
|
||
}
|
||
|
||
# Consolidações que contêm QUALQUER das operações informadas (jsonb).
|
||
# SEGURANÇA: os nomes passam por whitelist (Operacao.sanitizar) e por quote.
|
||
scope :com_operacoes, ->(ops) {
|
||
validas = Operacao.sanitizar(ops)
|
||
next all if validas.empty?
|
||
|
||
lista = validas.map { |o| connection.quote(o) }.join(',')
|
||
where("EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(operacoes, '[]'::jsonb)) e WHERE e IN (#{lista}))")
|
||
}
|
||
|
||
# ── Métodos ─────────────────────────────────────────────────
|
||
|
||
# Arquivar é um soft-delete administrativo: deve SEMPRE funcionar, mesmo numa
|
||
# consolidação zerada ou em rascunho com dados incompletos. Por isso usamos
|
||
# update_columns — pula validações (periodo_valido, presença) e o callback
|
||
# recalcular_valor_total, que não fazem sentido ao apenas mover para o arquivo.
|
||
def arquivar!(user)
|
||
update_columns(
|
||
deleted_at: Time.current,
|
||
status: self.class.statuses[:arquivada],
|
||
arquivado_por: user&.id,
|
||
arquivado_em: Time.current,
|
||
updated_at: Time.current
|
||
)
|
||
end
|
||
|
||
# Restaura uma consolidação arquivada. Como o status original não é guardado,
|
||
# inferimos: se já tinha sido finalizada (finalizado_em presente) volta para
|
||
# :finalizada, senão para :rascunho. Limpa os campos de arquivamento.
|
||
# Também via update_columns para não depender da validade do conteúdo.
|
||
def reativar!(_user)
|
||
update_columns(
|
||
deleted_at: nil,
|
||
status: self.class.statuses[finalizado_em? ? :finalizada : :rascunho],
|
||
arquivado_por: nil,
|
||
arquivado_em: nil,
|
||
updated_at: Time.current
|
||
)
|
||
end
|
||
|
||
# ── Pagamento (status derivado dos motoristas da consolidação) ──
|
||
# :pendente (nenhum pago) · :pago (todos pagos) · :parcial (alguns).
|
||
def status_pagamento
|
||
total = consolidacao_motoristas.ativos.count
|
||
return :pendente if total.zero?
|
||
|
||
pagos = consolidacao_motoristas.ativos.pagos.count
|
||
return :pendente if pagos.zero?
|
||
|
||
pagos >= total ? :pago : :parcial
|
||
end
|
||
|
||
def valor_pago
|
||
consolidacao_motoristas.ativos.pagos.sum(:valor_total)
|
||
end
|
||
|
||
def valor_pendente
|
||
consolidacao_motoristas.ativos.pendentes.sum(:valor_total)
|
||
end
|
||
|
||
def finalizar!(user)
|
||
return false unless todas_entregas_classificadas?
|
||
|
||
transaction do
|
||
# Finalizar a consolidação fecha (consolida) os veículos que ainda estavam
|
||
# abertos — mantém o estado por veículo coerente com o todo finalizado.
|
||
consolidacao_veiculos.ativos.abertos.update_all(
|
||
fechado_em: Time.current, fechado_por: user.id, updated_at: Time.current
|
||
)
|
||
update!(
|
||
status: :finalizada,
|
||
finalizado_por: user.id,
|
||
finalizado_em: Time.current
|
||
)
|
||
end
|
||
end
|
||
|
||
# Só está completa quando CADA motorista ativo está completo (nada por
|
||
# classificar, descontados os veículos arquivados). Fechar veículo é um passo
|
||
# independente; ao finalizar, os veículos abertos são consolidados juntos.
|
||
def todas_entregas_classificadas?
|
||
consolidacao_motoristas.ativos.all? { |cm| motorista_completo?(cm.motorista_nome) }
|
||
end
|
||
|
||
# Completude de UM motorista: não sobra entrega elegível por classificar. Os
|
||
# veículos ARQUIVADOS saem da conta (suas entregas deixam de ser exigidas),
|
||
# permitindo finalizar deixando um carro problemático de fora. Fechar um veículo
|
||
# é um passo independente (lock) — a finalização em si fecha os que restarem.
|
||
def motorista_completo?(motorista)
|
||
total = entregas_elegiveis_count(motorista) - eligible_veiculos_arquivados(motorista)
|
||
total.zero? || classificadas_count(motorista) >= total
|
||
end
|
||
|
||
def percentual_classificado
|
||
return 0 if total_entregas_estimadas.zero?
|
||
((consolidacao_entregas.count.to_f / total_entregas_estimadas) * 100).round
|
||
end
|
||
|
||
def total_entregas_estimadas
|
||
@total_entregas_estimadas ||= consolidacao_motoristas.ativos.sum do |cm|
|
||
entregas_elegiveis_count(cm.motorista_nome)
|
||
end
|
||
end
|
||
|
||
# Entregas elegíveis para um motorista no período (atendidas: sucesso +
|
||
# insucesso, pois o motorista foi ao local), respeitando o filtro de veículos
|
||
# da consolidação (quando houver). Fonte única usada pelo wizard, pela revisão
|
||
# e pelo gate de finalização.
|
||
def entregas_elegiveis_count(motorista)
|
||
Entrega.contar_atendidas(
|
||
inicio: data_inicio,
|
||
fim: data_fim,
|
||
motorista: motorista,
|
||
vehicle: vehicle_ids.presence,
|
||
operacoes: operacoes.presence
|
||
)
|
||
end
|
||
|
||
# Elegíveis de UM veículo específico do motorista (respeita filtros da
|
||
# consolidação). O bucket "(sem veículo)" casa as entregas com vehicle NULL/vazio.
|
||
def entregas_elegiveis_count_veiculo(motorista, vehicle)
|
||
rel = Entrega.atendidas.da_conta_gade
|
||
.no_periodo_checkout(data_inicio, data_fim)
|
||
.do_motorista(motorista)
|
||
rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present?
|
||
rel = rel.da_operacoes(operacoes) if operacoes.present?
|
||
rel = vehicle == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.where(vehicle: vehicle)
|
||
rel.count
|
||
end
|
||
|
||
# Total de elegíveis dos veículos ARQUIVADOS do motorista — descontado do gate
|
||
# para que arquivar um carro problemático libere a finalização. Só toca a base
|
||
# externa quando existe algum veículo arquivado (mantém os testes isolados).
|
||
def eligible_veiculos_arquivados(motorista)
|
||
arquivados = consolidacao_veiculos.arquivados.where(motorista_nome: motorista)
|
||
return 0 if arquivados.empty?
|
||
|
||
arquivados.sum { |cv| entregas_elegiveis_count_veiculo(motorista, cv.vehicle) }
|
||
end
|
||
|
||
# Rótulos amigáveis das operações vinculadas (para exibição).
|
||
def operacoes_label
|
||
Array(operacoes).map { |t| Operacao.label(t) }
|
||
end
|
||
|
||
# Entrega conta como classificada se tiver ≥1 pilar (distinct por tracking_id,
|
||
# já que agora pode haver vários pilares por entrega).
|
||
# Apontamentos manuais (manual: true) NÃO entram aqui: eles vivem fora do
|
||
# conjunto elegível do período, então contá-los inflaria o progresso para
|
||
# além de 100% (e quebraria o gate classificadas >= elegíveis).
|
||
def classificadas_count(motorista)
|
||
consolidacao_entregas.where(motorista_nome: motorista, manual: false)
|
||
.distinct.count(:tracking_id)
|
||
end
|
||
|
||
# Status por veículo do motorista: array de { vehicle, eligible, classificadas,
|
||
# completo, fechado, arquivado }. `completo` = 100% classificado (pode consolidar);
|
||
# `fechado` = veículo consolidado (🔒); `arquivado` = fora do gate. Une os veículos
|
||
# com entregas elegíveis (rastreio) com os que só têm linha em consolidacao_veiculos
|
||
# (arquivados / buckets manuais). Fonte única do filtro de carros e do wizard.
|
||
def veiculos_do_motorista(motorista)
|
||
return [] unless motorista.present?
|
||
|
||
rel = Entrega.atendidas.da_conta_gade
|
||
.no_periodo_checkout(data_inicio, data_fim)
|
||
.do_motorista(motorista)
|
||
rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present?
|
||
rel = rel.da_operacoes(operacoes) if operacoes.present?
|
||
|
||
por_veiculo = rel.pluck(:tracking_id, :vehicle).group_by { |(_id, v)| v.presence || SEM_VEICULO }
|
||
classificadas = consolidacao_entregas.where(motorista_nome: motorista, manual: false)
|
||
.distinct.pluck(:tracking_id).to_set
|
||
estados = consolidacao_veiculos.where(motorista_nome: motorista).index_by(&:vehicle)
|
||
|
||
(por_veiculo.keys + estados.keys).uniq.map do |veiculo|
|
||
ids = (por_veiculo[veiculo] || []).map(&:first)
|
||
feito = ids.count { |id| classificadas.include?(id) }
|
||
cv = estados[veiculo]
|
||
{ vehicle: veiculo, eligible: ids.size, classificadas: feito,
|
||
completo: ids.size.positive? && feito >= ids.size,
|
||
fechado: cv&.fechado? || false,
|
||
arquivado: cv&.arquivado? || false }
|
||
end.sort_by { |v| v[:vehicle] }
|
||
end
|
||
|
||
# Arquiva os veículos PENDENTES (com entregas elegíveis mas não 100% classificadas)
|
||
# de todos os motoristas ativos, deixando-os de fora da finalização. Usado pelo
|
||
# atalho "arquivar pendentes e finalizar" — os prontos/consolidados seguem no todo.
|
||
# Retorna a lista "Motorista · Veículo" arquivada.
|
||
def arquivar_veiculos_pendentes!(user)
|
||
arquivados = []
|
||
consolidacao_motoristas.ativos.each do |cm|
|
||
veiculos_do_motorista(cm.motorista_nome).each do |v|
|
||
next if v[:fechado] || v[:arquivado] || v[:completo] || v[:eligible].zero?
|
||
|
||
cv = consolidacao_veiculos.find_or_create_by!(motorista_nome: cm.motorista_nome, vehicle: v[:vehicle])
|
||
cv.arquivar!(user)
|
||
arquivados << "#{cm.motorista_nome} · #{v[:vehicle]}"
|
||
end
|
||
end
|
||
arquivados
|
||
end
|
||
|
||
# ── Apontamento manual ──────────────────────────────────────
|
||
# Adiciona uma entrega "por fora" (NF que chegou depois) com um ou mais pilares
|
||
# (tipos). O motorista vem da própria entrega e é incluído na consolidação se
|
||
# ainda não estiver. Retorna o nome do motorista. Idempotente por (tracking, tipo).
|
||
def adicionar_apontamento!(entrega, tipos, user)
|
||
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?
|
||
|
||
motorista = entrega.driver.to_s.strip
|
||
raise ArgumentError, 'Entrega sem motorista — não é possível apontar.' if motorista.blank?
|
||
|
||
transaction do
|
||
consolidacao_motoristas.find_or_create_by!(motorista_nome: motorista)
|
||
|
||
tipos_validos.each do |tipo|
|
||
ce = consolidacao_entregas.find_or_initialize_by(tracking_id: entrega.tracking_id, tipo: tipo)
|
||
next if ce.persisted? # pilar já existe — mantém
|
||
|
||
ce.assign_attributes(
|
||
motorista_nome: motorista,
|
||
valor_aplicado: ConsolidacaoEntrega.valor_para(tipo),
|
||
manual: true,
|
||
vehicle: entrega.try(:vehicle).presence, # snapshot do veículo da rastreio
|
||
created_by: user&.id
|
||
)
|
||
ce.save!
|
||
end
|
||
|
||
recalcular_motorista!(motorista)
|
||
end
|
||
|
||
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, vehicle: 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,
|
||
vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3)
|
||
nf_manual: nf.presence,
|
||
obs_manual: obs.presence,
|
||
created_by: user&.id
|
||
)
|
||
end
|
||
|
||
recalcular_motorista!(nome)
|
||
end
|
||
|
||
nome
|
||
end
|
||
|
||
# ── Entrega de termo ────────────────────────────────────────
|
||
# Lança um LOTE de N "entregas de termo" — entregas sem NF nem código de rastreio
|
||
# para atrelar (só motorista + quantidade, a um preço fixo configurável). Fica
|
||
# numa única linha (manual: true): quantidade guarda o N e valor_aplicado guarda
|
||
# o total do lote, então recalcular_motorista! e demais somas seguem inalteradas.
|
||
# `tipo` distingue termo normal ('termo') de termo especial ('termo_especial'),
|
||
# cada um com seu preço configurável.
|
||
def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil, tipo: 'termo')
|
||
nome = motorista.to_s.strip
|
||
raise ArgumentError, 'Informe o motorista do termo.' if nome.blank?
|
||
|
||
tipo = tipo.to_s
|
||
raise ArgumentError, 'Tipo de termo inválido.' unless %w[termo termo_especial].include?(tipo)
|
||
|
||
qtd = quantidade.to_i
|
||
raise ArgumentError, 'Informe uma quantidade válida de termos.' if qtd < 1
|
||
|
||
transaction do
|
||
consolidacao_motoristas.find_or_create_by!(motorista_nome: nome)
|
||
|
||
consolidacao_entregas.create!(
|
||
tracking_id: "TERMO-#{SecureRandom.uuid}", # sintético: não há Entrega/NF
|
||
tipo: tipo,
|
||
motorista_nome: nome,
|
||
quantidade: qtd,
|
||
valor_aplicado: qtd * ConsolidacaoEntrega.valor_para(tipo), # total do lote
|
||
manual: true,
|
||
vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3)
|
||
created_by: user&.id
|
||
)
|
||
|
||
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)
|
||
cm = consolidacao_motoristas.find_by(motorista_nome: nome)
|
||
return unless cm
|
||
|
||
anterior = cm.valor_total
|
||
total = consolidacao_entregas.where(motorista_nome: nome).sum do |e|
|
||
e.desconto? ? -e.valor_aplicado : e.valor_aplicado
|
||
end
|
||
cm.update_column(:valor_total, total)
|
||
update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total))
|
||
sincronizar_veiculos!(nome)
|
||
|
||
# Funil ÚNICO de mudança de valor do motorista — todo caminho que altera
|
||
# pilar, desconto ou lançamento termina aqui. O gatilho só vale depois do
|
||
# fechamento: em rascunho o valor muda a cada clique do wizard e avisar ali
|
||
# seria spam, não informação. Nunca levanta (ver Notificacao::Gatilhos).
|
||
Notificacao::Gatilhos.valor_alterado(self, cm, anterior: anterior)
|
||
end
|
||
|
||
# Mantém as linhas de consolidacao_veiculos do motorista em dia a partir dos
|
||
# pilares já classificados (100% DB — não toca a base externa). Cria/atualiza uma
|
||
# linha por veículo presente nas classificações e remove as que ficaram sem
|
||
# pilares e ainda não foram fechadas/arquivadas. Veículos elegíveis mas 100% sem
|
||
# classificar não geram linha aqui — o gate já os pega por classificadas < total.
|
||
def sincronizar_veiculos!(nome)
|
||
entregas = consolidacao_entregas.where(motorista_nome: nome)
|
||
presentes = entregas.pluck(:vehicle).map { |v| v.presence || SEM_VEICULO }.uniq
|
||
|
||
presentes.each do |veic|
|
||
cv = consolidacao_veiculos.find_or_initialize_by(motorista_nome: nome, vehicle: veic)
|
||
cv.save! # before_save :recalcular_valor atualiza o valor_total do veículo
|
||
end
|
||
|
||
consolidacao_veiculos.where(motorista_nome: nome)
|
||
.where(fechado_em: nil, arquivado_em: nil)
|
||
.where.not(vehicle: presentes)
|
||
.destroy_all
|
||
end
|
||
|
||
private
|
||
|
||
def periodo_valido
|
||
return unless data_inicio && data_fim
|
||
errors.add(:data_fim, 'deve ser após a data de início') if data_fim < data_inicio
|
||
end
|
||
|
||
def recalcular_valor_total
|
||
self.valor_total = consolidacao_motoristas.ativos.sum(:valor_total)
|
||
end
|
||
end
|