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

219 lines
9.0 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/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
# Status que o SimpleRoute grava para entregas SEM SUCESSO (falha/insucesso).
# AJUSTE aqui se o valor real for diferente (ex.: 'cancelled', 'not_delivered').
STATUS_FALHA = %w[failed].freeze
# Status em que o motorista FOI até o local: sucesso ('completed') ou
# insucesso ('failed'). Base da elegibilidade na consolidação — o motorista
# se deslocou, então a entrega precisa constar para ser classificada, mesmo
# que não tenha sido entregue com sucesso.
STATUS_ATENDIDO = (%w[completed] + STATUS_FALHA).freeze
# ── 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 }
# Atendidas = motorista foi ao local (sucesso OU insucesso) com checkout.
# É o conjunto elegível da consolidação (mais amplo que .pagas).
scope :atendidas, -> { where(status: STATUS_ATENDIDO).com_checkout }
# Falhadas = entregas que não tiveram sucesso (status de falha)
scope :falhadas, -> { where(status: STATUS_FALHA) }
# Pendentes = nem concluídas nem falhadas (ainda em aberto). Exclui as falhadas
# para que entregues/pendentes/falhadas não se sobreponham no dashboard.
scope :pendentes, -> { where.not(status: ['completed'] + STATUS_FALHA) }
# 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 = <tabela_operacao>.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, -> {
valores = contas_gade
valores.nil? ? all : where(account_id: valores)
}
# ── Métodos de classe ────────────────────────────────────────
# Contas aceitas (DB_EXISTING_ACCOUNT_ID), normalizadas. `nil` = sem filtro
# ("all"/vazio); um token vazio ("95907,") inclui também as linhas sem conta.
def self.contas_gade
contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip
return nil if contas.empty? || contas.casecmp?('all')
valores = contas.split(',', -1).map(&:strip)
valores << nil if valores.any?(&:empty?)
valores.uniq
end
# A MESMA condição do scope :da_conta_gade, como fragmento SQL — para as
# queries cruas de Analytics (que montam UNION por operação e não passam pelo
# ActiveRecord) usarem exatamente o mesmo recorte de conta do dashboard.
# Devolve nil quando não há filtro. `apelido` é o alias da tabela na query.
def self.condicao_conta_sql(apelido = table_name)
valores = contas_gade
return nil if valores.nil?
coluna = "#{connection.quote_table_name(apelido)}.account_id"
listadas = valores.compact
partes = []
partes << sanitize_sql_array(["#{coluna} IN (?)", listadas]) if listadas.any?
partes << "#{coluna} IS NULL" if valores.include?(nil)
"(#{partes.join(' OR ')})"
end
# 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.
#
# ⚠️ Precisa usar EXATAMENTE o mesmo recorte da elegibilidade
# (`atendidas` + `no_periodo_checkout`, ver contar_atendidas e
# Consolidacao#veiculos_do_motorista). Usava `no_periodo` (planned_date), e o
# descasamento sumia com veículo do filtro: entrega planejada em 31/07 e
# entregue em 01/08 é elegível numa consolidação 01/0814/08 (checkout dentro),
# mas o veículo dela não aparecia (planned_date fora) — sem opção de marcar,
# a entrega ficava de fora do fechamento SEM aviso, e o financeiro não batia.
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.atendidas.no_periodo_checkout(inicio, fim) if inicio.present? && fim.present?
base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?)
end
# Contagem de entregas ATENDIDAS (motorista foi ao local: sucesso + insucesso)
# elegíveis para a consolidação.
# Período pela DATA REAL da entrega (checkout) — ver no_periodo_checkout.
def self.contar_atendidas(inicio:, fim:, motorista: nil, vehicle: nil, operacoes: nil)
base = atendidas.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
# Insucesso: o motorista foi ao local mas a entrega não foi concluída.
def falhada?
STATUS_FALHA.include?(status)
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