# app/models/entrega.rb # # Model de LEITURA para a tabela existente da Reem Transporte. # NUNCA criar migration para esta tabela. # NUNCA executar INSERT, UPDATE, DELETE ou DROP nesta tabela. # class Entrega < ApplicationRecord self.table_name = 'db_reem_simplerout_2026' self.primary_key = 'tracking_id' # Apenas leitura — segurança contra mutações acidentais def readonly? true end # ── Scopes ────────────────────────────────────────────────── scope :concluidas, -> { where(status: 'completed') } scope :com_checkout, -> { where.not(checkout: nil) } # Pagamento = entrega concluída com checkout registrado scope :pagas, -> { concluidas.com_checkout } scope :pendentes, -> { where.not(status: 'completed') } # planned_date é timestamp: usamos o dia inteiro do limite superior para não # descartar as entregas do último dia do período (causava dashboard zerado e # subcontagem de elegíveis nas consolidações). scope :no_periodo, ->(inicio, fim) { where(planned_date: inicio.to_date.beginning_of_day..fim.to_date.end_of_day) } # Período pela DATA REAL da entrega (checkout) — usada nas análises financeiras # ("quando a entrega foi concluída"). Comparação naïve com Date (intervalo # meio-aberto: >= início e < fim+1), SEM conversão de fuso — bate com a análise # feita direto no banco. Só faz sentido sobre entregas .pagas (checkout != nil). scope :no_periodo_checkout, ->(inicio, fim) { where('checkout >= ? AND checkout < ?', inicio.to_date, fim.to_date + 1) } scope :do_motorista, ->(nome) { where(driver: nome) } # Busca pela NF (reference_id). A coluna é numérica, então comparamos como # texto para aceitar a entrada do usuário de forma robusta. Usado no # apontamento manual de entregas (NF que chegou fora do período). scope :por_nf, ->(nf) { where('reference_id::text = ?', nf.to_s.strip) } scope :da_veiculo, ->(vehicle) { where(vehicle: vehicle) } # Restringe às entregas cujas NFs (reference_id) constam na(s) tabela(s) de # operação informada(s). O JOIN é o mesmo do script de gestão do cliente: # reference_id::text = .nota_fiscal # # SEGURANÇA: o nome da tabela entra no SQL, então passa pela whitelist # (Operacao.sanitizar) e por quote_table_name. Nomes inválidos são descartados; # se nada sobrar, não filtra (all) — o filtro de operação é opcional. scope :da_operacoes, ->(tabelas) { validas = Operacao.sanitizar(tabelas) next all if validas.empty? selects = validas.map { |t| "SELECT nota_fiscal FROM #{connection.quote_table_name(t)}" } where("reference_id::text IN (#{selects.join(' UNION ')})") } scope :do_mes, ->(data = Date.current) { where(planned_date: data.beginning_of_month..data.end_of_month) } # Filtra pela(s) conta(s) configurada(s) em DB_EXISTING_ACCOUNT_ID. # A coluna account_id é TEXTO e pode vir vazia/NULL, então comparamos como string. # IMPORTANTE: o valor é uma LISTA separada por vírgula — NÃO um trecho de SQL. # Aceita: "95907" (uma) | "95907,12345" (várias) | "all" ou vazio (sem filtro). # Use "95907," (vírgula no fim) para incluir também registros de conta vazia/NULL. scope :da_conta_gade, -> { contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip if contas.empty? || contas.casecmp?('all') all else valores = contas.split(',', -1).map(&:strip) # Token vazio (ex.: "95907,") → inclui também as linhas sem conta (NULL). valores << nil if valores.any?(&:empty?) where(account_id: valores) end } # ── Métodos de classe ──────────────────────────────────────── # Lista motoristas únicos (para selects, consolidações) def self.motoristas_ativos(inicio: nil, fim: nil) base = da_conta_gade base = base.no_periodo(inicio, fim) if inicio && fim base.distinct.order(:driver).pluck(:driver).compact.reject(&:empty?) end # Lista veículos únicos (para selects/filtros de consolidação) def self.veiculos_unicos(inicio: nil, fim: nil) base = da_conta_gade base = base.no_periodo(inicio, fim) if inicio && fim base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?) end # Veículos usados pelo(s) motorista(s) selecionado(s) no período — alimenta o # filtro dinâmico de veículos na criação de consolidação. def self.veiculos_de_motoristas(motoristas, inicio: nil, fim: nil) nomes = Array(motoristas).reject(&:blank?) return [] if nomes.empty? base = da_conta_gade.where(driver: nomes) base = base.no_periodo(inicio, fim) if inicio.present? && fim.present? base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?) end # Contagem de entregas pagas para cálculo estimado. # Período pela DATA REAL da entrega (checkout) — ver no_periodo_checkout. def self.contar_pagas(inicio:, fim:, motorista: nil, vehicle: nil, operacoes: nil) base = pagas.da_conta_gade.no_periodo_checkout(inicio, fim) base = base.do_motorista(motorista) if motorista.present? base = base.da_veiculo(vehicle) if vehicle.present? base = base.da_operacoes(operacoes) if operacoes.present? base.count end # ── Helpers de instância ───────────────────────────────────── def numero_nf reference_id end # Resumo para o apontamento manual (modal de busca de NF). Fonte única usada # pelos dois fluxos: apontar em consolidação existente e criar "nota avulsa". def resumo_apontamento { tracking_id: tracking_id, nf: numero_nf, motorista: driver, veiculo: vehicle, local: local, status: status, pago: elegivel_pagamento?, data: (checkout || planned_date)&.to_s } end def local contact_name.presence || address end def concluida? status == 'completed' end def checkout_registrado? checkout.present? end def elegivel_pagamento? concluida? && checkout_registrado? end def atraso_minutos return 0 unless delay.present? delay.to_s.split(':').then { |h, m, _s| h.to_i * 60 + m.to_i } end end