# 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) } scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) } # ── 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 # ── 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. def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil) nome = motorista.to_s.strip raise ArgumentError, 'Informe o motorista do termo.' if nome.blank? 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: 'termo', motorista_nome: nome, quantidade: qtd, valor_aplicado: qtd * ConsolidacaoEntrega.valor_para('termo'), # 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 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) 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