Files
Reem-Notas/app/models/consolidacao.rb

436 lines
19 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/0826/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/0826/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