Fases 4, 5 e 6 — Job 1h + Auditoria + Consolidação + Wizard de validação
This commit is contained in:
@@ -1,22 +1,29 @@
|
||||
# app/models/auditoria_log.rb
|
||||
class AuditoriaLog < ApplicationRecord
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :user, optional: true # opcional caso user seja deletado
|
||||
|
||||
validates :acao, presence: true
|
||||
ACOES = %w[criar editar finalizar arquivar excluir login logout].freeze
|
||||
|
||||
validates :acao, presence: true
|
||||
validates :entidade, presence: true
|
||||
|
||||
scope :recentes, -> { order(created_at: :desc) }
|
||||
scope :do_usuario, ->(user) { where(user: user) }
|
||||
scope :por_user, ->(user_id) { where(user_id: user_id) }
|
||||
scope :por_entidade, ->(entidade) { where(entidade: entidade) }
|
||||
|
||||
# Cria um log de auditoria de forma simples
|
||||
# AuditoriaLog.registrar(current_user, 'criar_consolidacao', ip, detalhes: '...')
|
||||
def self.registrar(user, acao, ip = nil, detalhes: nil)
|
||||
def self.registrar(user:, acao:, entidade:, entidade_id: nil,
|
||||
dados_anteriores: {}, dados_novos: {}, request: nil)
|
||||
create!(
|
||||
user: user,
|
||||
acao: acao,
|
||||
ip: ip,
|
||||
detalhes: detalhes
|
||||
user_id: user&.id,
|
||||
acao: acao.to_s,
|
||||
entidade: entidade.to_s,
|
||||
entidade_id: entidade_id,
|
||||
dados_anteriores: dados_anteriores,
|
||||
dados_novos: dados_novos,
|
||||
ip_address: request&.remote_ip,
|
||||
user_agent: request&.user_agent
|
||||
)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.warn("AuditoriaLog falhou: #{e.message}")
|
||||
rescue => e
|
||||
Rails.logger.error("[AuditoriaLog] Erro ao registrar: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,39 +1,55 @@
|
||||
# app/models/configuracao.rb
|
||||
class Configuracao < ApplicationRecord
|
||||
TIPOS = %w[entrega retirada bonus desconto].freeze
|
||||
# Chaves válidas do sistema
|
||||
CHAVES = %w[
|
||||
preco_entrega
|
||||
preco_retirada
|
||||
preco_bonus
|
||||
preco_desconto
|
||||
notificacao_whatsapp
|
||||
notificacao_email
|
||||
empresa_nome
|
||||
empresa_logo
|
||||
].freeze
|
||||
|
||||
enum tipo: { entrega: 0, retirada: 1, bonus: 2, desconto: 3 }
|
||||
CHAVES_MOEDA = %w[
|
||||
preco_entrega
|
||||
preco_retirada
|
||||
preco_bonus
|
||||
preco_desconto
|
||||
].freeze
|
||||
|
||||
validates :tipo, presence: true, uniqueness: true
|
||||
validates :valor, presence: true,
|
||||
numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true
|
||||
validates :valor, presence: true
|
||||
|
||||
LABELS = {
|
||||
'entrega' => 'Entrega Normal',
|
||||
'retirada' => 'Retirada',
|
||||
'bonus' => 'Bônus',
|
||||
'desconto' => 'Desconto'
|
||||
}.freeze
|
||||
# ── Acesso rápido ───────────────────────────────────────────
|
||||
|
||||
ICONES = {
|
||||
'entrega' => '🚚',
|
||||
'retirada' => '📦',
|
||||
'bonus' => '⭐',
|
||||
'desconto' => '🔻'
|
||||
}.freeze
|
||||
|
||||
def tipo_label
|
||||
LABELS[tipo] || tipo.humanize
|
||||
def self.valor(chave)
|
||||
find_by(chave: chave)&.valor
|
||||
end
|
||||
|
||||
def icone
|
||||
ICONES[tipo] || '💰'
|
||||
def self.preco_entrega
|
||||
valor('preco_entrega').to_f
|
||||
end
|
||||
|
||||
# Retorna um hash { entrega: 10.50, retirada: 8.00, bonus: 5.00, desconto: 2.00 }
|
||||
def self.mapa_de_precos
|
||||
all.each_with_object({}) do |c, h|
|
||||
h[c.tipo.to_sym] = c.valor.to_f
|
||||
end
|
||||
def self.preco_retirada
|
||||
valor('preco_retirada').to_f
|
||||
end
|
||||
|
||||
def self.preco_bonus
|
||||
valor('preco_bonus').to_f
|
||||
end
|
||||
|
||||
def self.preco_desconto
|
||||
valor('preco_desconto').to_f
|
||||
end
|
||||
|
||||
def moeda?
|
||||
CHAVES_MOEDA.include?(chave)
|
||||
end
|
||||
|
||||
def valor_formatado
|
||||
return "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}" if moeda?
|
||||
valor
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,34 +1,75 @@
|
||||
# app/models/consolidacao.rb
|
||||
class Consolidacao < ApplicationRecord
|
||||
# Soft delete
|
||||
acts_as_paranoid if respond_to?(:acts_as_paranoid)
|
||||
belongs_to :criador, class_name: 'User', foreign_key: :created_by
|
||||
belongs_to :finalizador, class_name: 'User', foreign_key: :finalizado_por, optional: true
|
||||
|
||||
enum status: { aberta: 0, em_revisao: 1, fechada: 2, aprovada: 3, cancelada: 4 }
|
||||
|
||||
belongs_to :criado_por, class_name: 'User', foreign_key: :user_id
|
||||
has_many :consolidacao_motoristas, dependent: :destroy
|
||||
has_many :consolidacao_entregas, through: :consolidacao_motoristas
|
||||
has_many :motoristas, through: :consolidacao_motoristas, source: :user
|
||||
has_many :consolidacao_entregas, dependent: :destroy
|
||||
|
||||
validates :referencia_mes, presence: true
|
||||
validates :status, presence: true
|
||||
# ── Enums ───────────────────────────────────────────────────
|
||||
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 }
|
||||
|
||||
scope :do_mes, ->(data = Date.today) {
|
||||
where(referencia_mes: data.beginning_of_month..data.end_of_month)
|
||||
}
|
||||
# ── Soft delete ─────────────────────────────────────────────
|
||||
scope :ativas, -> { where(deleted_at: nil) }
|
||||
scope :arquivadas, -> { where.not(deleted_at: nil) }
|
||||
|
||||
scope :abertas, -> { where(status: :aberta) }
|
||||
scope :fechadas, -> { where(status: [:fechada, :aprovada]) }
|
||||
# ── Validações ──────────────────────────────────────────────
|
||||
validates :nome, presence: true
|
||||
validates :data_inicio, presence: true
|
||||
validates :data_fim, presence: true
|
||||
validates :created_by, presence: true
|
||||
validate :periodo_valido
|
||||
|
||||
def progresso_percentual
|
||||
return 0 if consolidacao_motoristas.none?
|
||||
# ── Callbacks ───────────────────────────────────────────────
|
||||
before_save :recalcular_valor_total
|
||||
|
||||
finalizados = consolidacao_motoristas.where(finalizado: true).count
|
||||
total = consolidacao_motoristas.count
|
||||
((finalizados.to_f / total) * 100).round
|
||||
# ── Escopos ─────────────────────────────────────────────────
|
||||
scope :recentes, -> { order(created_at: :desc) }
|
||||
scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) }
|
||||
|
||||
# ── Métodos ─────────────────────────────────────────────────
|
||||
|
||||
def arquivar!(user)
|
||||
update!(deleted_at: Time.current, status: :arquivada)
|
||||
end
|
||||
|
||||
def mes_label
|
||||
referencia_mes&.strftime('%B/%Y')&.capitalize
|
||||
def finalizar!(user)
|
||||
return false unless todas_entregas_classificadas?
|
||||
|
||||
update!(
|
||||
status: :finalizada,
|
||||
finalizado_por: user.id,
|
||||
finalizado_em: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def todas_entregas_classificadas?
|
||||
total_entregas = ConsolidacaoMotorista
|
||||
.where(consolidacao_id: id)
|
||||
.sum { |cm| Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome) }
|
||||
|
||||
consolidacao_entregas.count >= total_entregas
|
||||
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.sum do |cm|
|
||||
Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome)
|
||||
end
|
||||
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.sum(:valor_total)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,39 +1,88 @@
|
||||
# app/models/entrega.rb
|
||||
# ⚠️ READ-ONLY — tabela existente: public.db_reem_simplerout_2026
|
||||
# NUNCA fazer DROP, TRUNCATE, DELETE ou migration nessa tabela.
|
||||
#
|
||||
# Model de LEITURA para a tabela existente da Gade Hospitalar.
|
||||
# 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.table_name = 'db_reem_simplerout_2026'
|
||||
self.primary_key = 'tracking_id'
|
||||
|
||||
# Apenas leitura
|
||||
# Apenas leitura — segurança contra mutações acidentais
|
||||
def readonly?
|
||||
true
|
||||
end
|
||||
|
||||
# Scopes
|
||||
scope :do_account, -> { where(account_id: 95907) }
|
||||
scope :pagas, -> { where(status: 'completed').where.not(checkin: nil) }
|
||||
scope :pendentes, -> { where.not(status: 'completed').or(where(checkin: nil)) }
|
||||
# ── Scopes ──────────────────────────────────────────────────
|
||||
scope :concluidas, -> { where(status: 'completed') }
|
||||
scope :com_checkin, -> { where.not(checkin: nil) }
|
||||
scope :pagas, -> { concluidas.com_checkin }
|
||||
scope :pendentes, -> { where.not(status: 'completed') }
|
||||
|
||||
scope :do_mes, ->(data = Date.today) {
|
||||
do_account
|
||||
.where(planned_date: data.beginning_of_month..data.end_of_month)
|
||||
}
|
||||
|
||||
scope :do_dia, ->(data = Date.today) {
|
||||
do_account.where(planned_date: data)
|
||||
scope :no_periodo, ->(inicio, fim) {
|
||||
where(planned_date: inicio.to_date..fim.to_date)
|
||||
}
|
||||
|
||||
scope :do_motorista, ->(nome) {
|
||||
where('LOWER(driver) = ?', nome.to_s.downcase)
|
||||
where(driver: nome)
|
||||
}
|
||||
|
||||
# Helpers de instância
|
||||
def paga?
|
||||
status == 'completed' && checkin.present?
|
||||
scope :da_rota, ->(route_id) {
|
||||
where(route_id: route_id)
|
||||
}
|
||||
|
||||
scope :da_conta_gade, -> {
|
||||
where(account_id: ENV.fetch('DB_EXISTING_ACCOUNT_ID', 95907).to_i)
|
||||
}
|
||||
|
||||
# ── 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
|
||||
|
||||
def data_formatada
|
||||
planned_date&.strftime('%d/%m/%Y')
|
||||
# Lista rotas únicas (route_id → descrição)
|
||||
def self.rotas_unicas(inicio: nil, fim: nil)
|
||||
base = da_conta_gade
|
||||
base = base.no_periodo(inicio, fim) if inicio && fim
|
||||
base.distinct.pluck(:route_id).compact
|
||||
end
|
||||
|
||||
# Contagem de entregas pagas para cálculo estimado
|
||||
def self.contar_pagas(inicio:, fim:, motorista: nil, route_id: nil)
|
||||
base = pagas.da_conta_gade.no_periodo(inicio, fim)
|
||||
base = base.do_motorista(motorista) if motorista.present?
|
||||
base = base.da_rota(route_id) if route_id.present?
|
||||
base.count
|
||||
end
|
||||
|
||||
# ── Helpers de instância ─────────────────────────────────────
|
||||
|
||||
def numero_nf
|
||||
reference_id
|
||||
end
|
||||
|
||||
def local
|
||||
contact_name.presence || address
|
||||
end
|
||||
|
||||
def concluida?
|
||||
status == 'completed'
|
||||
end
|
||||
|
||||
def checkin_registrado?
|
||||
checkin.present?
|
||||
end
|
||||
|
||||
def elegivel_pagamento?
|
||||
concluida? && checkin_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
|
||||
|
||||
@@ -1,59 +1,69 @@
|
||||
# app/models/user.rb
|
||||
class User < ApplicationRecord
|
||||
# Devise modules
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable,
|
||||
:trackable, :lockable
|
||||
devise :database_authenticatable,
|
||||
:registerable,
|
||||
:recoverable,
|
||||
:rememberable,
|
||||
:validatable
|
||||
|
||||
# Roles
|
||||
# ── Roles ──────────────────────────────────────────────────
|
||||
enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3 }
|
||||
|
||||
# Validações
|
||||
validates :name, presence: true
|
||||
validates :role, presence: true
|
||||
validates :pin_acesso,
|
||||
uniqueness: { allow_blank: true },
|
||||
length: { is: 4, allow_blank: true },
|
||||
format: { with: /\A\d{4}\z/, allow_blank: true, message: 'deve ter exatamente 4 dígitos numéricos' }
|
||||
ROLES_LABEL = {
|
||||
'admin' => 'Administrador',
|
||||
'gerente' => 'Gerente',
|
||||
'operador' => 'Operador',
|
||||
'motorista' => 'Motorista'
|
||||
}.freeze
|
||||
|
||||
validate :pin_obrigatorio_para_motorista
|
||||
# ── Validações ──────────────────────────────────────────────
|
||||
validates :nome, presence: true, length: { minimum: 3 }
|
||||
validates :role, presence: true
|
||||
validates :pin_code,
|
||||
length: { is: 4 },
|
||||
numericality: { only_integer: true },
|
||||
allow_nil: true,
|
||||
if: :motorista?
|
||||
|
||||
# Callbacks
|
||||
before_validation :normalizar_pin
|
||||
validate :pin_unico_para_motoristas, if: :motorista?
|
||||
|
||||
# Associações
|
||||
has_many :auditoria_logs, foreign_key: :user_id, dependent: :nullify
|
||||
has_many :consolidacao_motoristas, foreign_key: :user_id, dependent: :restrict_with_error
|
||||
# Motoristas podem não ter e-mail (login via PIN)
|
||||
def email_required?
|
||||
!motorista?
|
||||
end
|
||||
|
||||
# Scopes
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :motoristas, -> { where(role: :motorista) }
|
||||
def password_required?
|
||||
!motorista? ? super : false
|
||||
end
|
||||
|
||||
# ── Escopos ─────────────────────────────────────────────────
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :por_nome, -> { order(:nome) }
|
||||
scope :nao_motoristas, -> { where.not(role: :motorista) }
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────
|
||||
def nome_display
|
||||
name.split.first.capitalize
|
||||
nome.presence || email
|
||||
end
|
||||
|
||||
def active?
|
||||
ativo?
|
||||
def role_label
|
||||
ROLES_LABEL[role] || role.humanize
|
||||
end
|
||||
|
||||
def pode_acessar_admin?
|
||||
def pode_ver_config?
|
||||
admin? || gerente?
|
||||
end
|
||||
|
||||
def pode_criar_consolidacao?
|
||||
def pode_consolidar?
|
||||
admin? || gerente? || operador?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalizar_pin
|
||||
self.pin_acesso = pin_acesso.to_s.strip.presence
|
||||
end
|
||||
def pin_unico_para_motoristas
|
||||
return if pin_code.blank?
|
||||
|
||||
def pin_obrigatorio_para_motorista
|
||||
if motorista? && pin_acesso.blank?
|
||||
errors.add(:pin_acesso, 'é obrigatório para motoristas')
|
||||
end
|
||||
conflito = User.motorista.where(pin_code: pin_code).where.not(id: id)
|
||||
errors.add(:pin_code, 'já está em uso por outro motorista') if conflito.exists?
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user