Compare commits
7 Commits
teste
...
18239b133b
| Author | SHA256 | Date | |
|---|---|---|---|
| 18239b133b | |||
| dd5621a215 | |||
| a020f89ef1 | |||
| 2346fed20a | |||
| 7deac237d3 | |||
| a98e7868d2 | |||
| cb47d1c294 |
53
.env.example
53
.env.example
@@ -9,30 +9,8 @@
|
||||
# Use `development` só na sua máquina local.
|
||||
RAILS_ENV=production
|
||||
|
||||
# Fuso horário do sistema operacional dentro dos containers.
|
||||
# Já é o padrão no Dockerfile — só mexa se a operação mudar de estado.
|
||||
# ⚠️ Isto define o FUSO, não a HORA. Se a data/hora do servidor está errada
|
||||
# (atrasa, ou volta errada depois de reboot/deploy), o problema é o relógio
|
||||
# do HOST: rode uma vez `sudo bash deploy/ntp-seguro.sh`.
|
||||
TZ=America/Sao_Paulo
|
||||
|
||||
# ── Porta publicada no host ───────────────────────────────
|
||||
# A porta DENTRO do container é sempre 3000; esta é a do servidor.
|
||||
# Neste branch (teste) o padrão do docker-compose.yml JÁ É 3001, porque a 3000
|
||||
# está ocupada por outro stack no mesmo NAS. Esta linha só é necessária para
|
||||
# usar uma porta diferente da padrão.
|
||||
# ⚠️ Tem que casar com o destino do proxy reverso (DSM / Cloudflare).
|
||||
PORTA_APP=3001
|
||||
|
||||
# ── Banco de dados (seu PostgreSQL já existente) ─────────────
|
||||
# ⚠️ SÓ O `DATABASE_URL` É LIDO. Este projeto não tem config/database.yml — o
|
||||
# Rails monta a conexão a partir desta URL, e nenhum código lê DB_HOST,
|
||||
# DB_NAME, DB_USER ou DB_PASSWORD (elas ficam abaixo como documentação dos
|
||||
# valores que compõem a URL). Se editar só as DB_*, nada muda.
|
||||
# ⚠️ Senha com caractere especial precisa ser escapada na URL:
|
||||
# @ → %40 # → %23 / → %2F : → %3A
|
||||
# Deixar o valor de exemplo aqui derruba o boot com
|
||||
# "URI::InvalidURIError: ... SEU_IP".
|
||||
# Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL
|
||||
DATABASE_URL=postgresql://postgres:senha_segura@SEU_IP:5432/logistica_db
|
||||
DB_HOST=SEU_IP # ex: 192.168.1.100 ou db.gade.com.br
|
||||
DB_PORT=5432
|
||||
@@ -98,32 +76,5 @@ SMTP_DOMAIN=gade.com.br
|
||||
NOTIFICACAO_SECRET=
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────
|
||||
# ⚠️ SÓ O HOST, sem "https://". O código monta os links como
|
||||
# "https://#{APP_HOST}/motorista" (consolidacao_mailer.rb,
|
||||
# notificacao_service.rb, gatilhos.rb): com o esquema aqui sai
|
||||
# "https://https://..." e todo link de e-mail e de WhatsApp quebra.
|
||||
# O docker-compose.yml já traz o host do ambiente como padrão — esta linha só
|
||||
# é necessária para apontar para outro endereço.
|
||||
APP_HOST=teste.reemtransportes.com.br
|
||||
APP_HOST=localhost:3000
|
||||
APP_NAME=Reem Logística
|
||||
|
||||
# Hosts extras aceitos além do APP_HOST, separados por vírgula (só vale com
|
||||
# RAILS_ENV=production, onde o Rails checa o cabeçalho Host).
|
||||
# Use quando o sistema também for acessado por IP:porta na rede interna —
|
||||
# sem isso o Rails responde "Blocked hosts: <ip>:<porta>".
|
||||
HOSTS_PERMITIDOS=100.75.222.23
|
||||
|
||||
# ── WhatsApp (ponte própria — sessão pareada por QR code) ──────────────────
|
||||
# Token compartilhado entre o Rails e o container `whatsapp`. Qualquer string
|
||||
# longa e aleatória: `openssl rand -hex 32`. Sem ele a ponte recusa TUDO.
|
||||
WHATSAPP_TOKEN=troque_por_uma_string_aleatoria_longa
|
||||
# URL da ponte dentro da rede do compose (não mexa, a menos que troque o nome
|
||||
# do serviço no docker-compose.yml).
|
||||
# (o nome antigo `BAILEYS_URL` ainda é aceito, para .env já em produção)
|
||||
WHATSAPP_URL=http://whatsapp:3001
|
||||
|
||||
# ── Erros na tela ────────────────────────────────────────────────────────
|
||||
# Mostra a página de debug do Rails (parâmetros, SESSÃO, cookies, IP, trace).
|
||||
# Deixe SEMPRE vazia/false em qualquer servidor: esse painel vai para a tela de
|
||||
# quem provocou o erro. Ligue só na sua máquina, quando estiver depurando.
|
||||
ERROS_DETALHADOS=false
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -1,22 +1,6 @@
|
||||
FROM ruby:3.2.2-slim
|
||||
|
||||
# Fuso horário do container.
|
||||
#
|
||||
# POR QUE ISSO IMPORTA (caso real): a imagem sobe em UTC. O Rails até mostra a
|
||||
# hora certa (`config.time_zone = "America/Sao_Paulo"`), mas TUDO que é do
|
||||
# sistema operacional continuava 3h adiantado — timestamp de log, `date` nos
|
||||
# scripts de bin/ e, o pior, o CRON: `every "*/30 8-18"` no config/schedule.rb
|
||||
# rodava das 05h às 15h de Brasília, não das 08h às 18h.
|
||||
#
|
||||
# O cron do Debian lê `/etc/localtime`, não a variável TZ — por isso o symlink
|
||||
# além do ENV. Os dois juntos cobrem processo Ruby e daemon de cron.
|
||||
ENV TZ=America/Sao_Paulo
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Dependências do sistema
|
||||
# tzdata: explícito de propósito. O zoneinfo já vem na imagem hoje, mas se um
|
||||
# rebuild futuro pegar uma base enxuta o symlink acima quebra em silêncio e a
|
||||
# hora volta a ficar errada — que é exatamente o bug que estamos fechando.
|
||||
RUN apt-get update -qq && apt-get install -y \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
@@ -26,9 +10,6 @@ RUN apt-get update -qq && apt-get install -y \
|
||||
git \
|
||||
libvips \
|
||||
cron \
|
||||
tzdata \
|
||||
&& ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime \
|
||||
&& echo "$TZ" > /etc/timezone \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Diretório da app
|
||||
@@ -41,14 +22,9 @@ RUN bundle install --jobs 4 --retry 3
|
||||
# Copia o restante do código
|
||||
COPY . .
|
||||
|
||||
# Assets NÃO são pré-compilados aqui, e a linha comentada foi removida para
|
||||
# ninguém "descomentar para resolver": o compose monta o projeto por cima
|
||||
# (`volumes: - ".:/app"`), então o public/assets gerado na imagem some no boot.
|
||||
# Quem pré-compila é bin/docker-boot, com o código que está de fato rodando.
|
||||
# Pré-compila assets (em produção)
|
||||
# RUN bundle exec rails assets:precompile
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# O boot real (migrations, cron, servidor) mora em bin/docker-boot — ver os
|
||||
# comentários lá. CMD aqui é só o fallback de quem roda a imagem sem o compose.
|
||||
# (via `bash` — ver o motivo no docker-compose.yml)
|
||||
CMD ["bash", "bin/docker-boot"]
|
||||
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-param" content="authenticity_token">
|
||||
<meta name="csrf-token" content="G-9RRM76mXTElY86HJ18dlR93xoYQyO4uD1MzgEGhSZLg9p1i8wFt73pl0AHSNGSxXOBT0R5D0q_c_iovOAGZQ">
|
||||
<title>Reem Logística</title>
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
preto: '#0a0a0a',
|
||||
'preto-card': '#1a1a1a',
|
||||
laranja: '#f97316',
|
||||
'laranja-escuro': '#ea580c',
|
||||
branco: '#ffffff',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
/* Scrollbar tema escuro */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0a0a0a; }
|
||||
::-webkit-scrollbar-thumb { background: #f97316; border-radius: 3px; }
|
||||
/* Loading spinner laranja */
|
||||
.spinner {
|
||||
border: 3px solid #1a1a1a;
|
||||
border-top-color: #f97316;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<script type="importmap" data-turbo-track="reload">{
|
||||
"imports": {
|
||||
"application": "/assets/application-09caced1.js",
|
||||
"@hotwired/turbo-rails": "/assets/turbo.min-86bf8853.js",
|
||||
"@hotwired/stimulus": "/assets/stimulus.min-7ea3d58b.js",
|
||||
"@hotwired/stimulus-loading": "/assets/stimulus-loading-25917588.js",
|
||||
"controllers/application": "/assets/controllers/application-75a69c37.js",
|
||||
"controllers": "/assets/controllers/index-dfefe98d.js",
|
||||
"controllers/validacao_controller": "/assets/controllers/validacao_controller-3ce614b7.js"
|
||||
}
|
||||
}</script>
|
||||
<link rel="modulepreload" href="/assets/application-09caced1.js">
|
||||
<link rel="modulepreload" href="/assets/turbo.min-86bf8853.js">
|
||||
<link rel="modulepreload" href="/assets/stimulus.min-7ea3d58b.js">
|
||||
<link rel="modulepreload" href="/assets/stimulus-loading-25917588.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/application-75a69c37.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/index-dfefe98d.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/validacao_controller-3ce614b7.js">
|
||||
<script type="module">import "application"</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="bg-[#0a0a0a] text-white min-h-screen">
|
||||
|
||||
|
||||
<div id="sidebar-overlay"
|
||||
class="fixed inset-0 bg-black/70 z-30 hidden md:hidden"
|
||||
onclick="toggleSidebar()"></div>
|
||||
|
||||
<aside id="sidebar"
|
||||
class="fixed top-0 left-0 h-full w-64 bg-[#111111] border-r border-[#2a2a2a] z-40
|
||||
transform -translate-x-full md:translate-x-0 transition-transform duration-300">
|
||||
|
||||
<div class="flex items-center gap-3 px-6 py-5 border-b border-[#2a2a2a]">
|
||||
<div class="w-9 h-9 bg-orange-500 rounded-lg flex items-center justify-center font-black text-black text-lg">G</div>
|
||||
<div>
|
||||
<p class="font-bold text-white text-sm leading-tight">Reem Transporte</p>
|
||||
<p class="text-orange-500 text-xs">Logística</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-b border-[#2a2a2a]">
|
||||
<p class="text-xs text-gray-500 mb-1">Logado como</p>
|
||||
<p class="text-white font-semibold text-sm truncate">Administrador Gade</p>
|
||||
<span class="inline-block mt-1 px-2 py-0.5 text-xs rounded-full
|
||||
bg-orange-500 text-black">
|
||||
Administrador
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
|
||||
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/dashboard">📊 Dashboard</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/consolidacoes">📦 Consolidações</a>
|
||||
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
|
||||
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/usuarios">👥 Usuários</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/configuracoes">⚙️ Configurações</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/auditoria_logs">🔍 Auditoria</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="px-6 py-4 border-t border-[#2a2a2a]">
|
||||
<a data-turbo-method="delete" data-turbo-confirm="Deseja sair?" class="flex items-center gap-2 text-gray-400 hover:text-red-400 text-sm transition-colors" href="/auth/logout">
|
||||
<span>🚪</span> Sair
|
||||
</a> </div>
|
||||
</aside>
|
||||
|
||||
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
|
||||
<button onclick="toggleSidebar()"
|
||||
class="text-orange-500 text-2xl min-h-[48px] min-w-[48px] flex items-center justify-center">
|
||||
☰
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center font-black text-black text-sm">G</div>
|
||||
<span class="font-bold text-white text-sm">Reem Logística</span>
|
||||
</div>
|
||||
<div class="w-12"></div>
|
||||
</header>
|
||||
<div class="h-14 md:hidden"></div>
|
||||
|
||||
<script>
|
||||
function toggleSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
const o = document.getElementById('sidebar-overlay');
|
||||
s.classList.toggle('-translate-x-full');
|
||||
o.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
<main class="ml-0 md:ml-64 p-4 md:p-8 min-h-screen">
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">
|
||||
Novo Usuário
|
||||
</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Preencha os dados para criar um novo acesso
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<form class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6" action="/admin/usuarios" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="ENsNJddbt-MKLOBei4dMU8xI5qs3plOiQMzqfepJ4wk7d_oooUwVJUOzd7xxU_Kjqz8jnh7JOzPwpymLh0pwVQ" autocomplete="off" />
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_nome">Nome completo</label>
|
||||
<input class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
|
||||
focus:ring-[#f97316] transition-colors" placeholder="João da Silva" type="text" name="user[nome]" id="user_nome" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_role">Perfil de acesso</label>
|
||||
<select class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
|
||||
focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316]
|
||||
transition-colors cursor-pointer" name="user[role]" id="user_role"><option value="admin">Administrador</option>
|
||||
<option value="gerente">Gerente</option>
|
||||
<option value="operador">Operador</option>
|
||||
<option selected="selected" value="motorista">Motorista</option></select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-300" for="user_ativo">Usuário ativo</label>
|
||||
<p class="text-gray-500 text-xs mt-0.5">Usuários inativos não conseguem fazer login</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer ml-4">
|
||||
<input name="user[ativo]" type="hidden" value="0" autocomplete="off" /><input class="sr-only peer" type="checkbox" value="1" checked="checked" name="user[ativo]" id="user_ativo" />
|
||||
<div class="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:border-gray-300 after:border after:rounded-full
|
||||
after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fields-email" class="space-y-6">
|
||||
<div class="border-t border-white/5 pt-6">
|
||||
<p class="text-sm font-medium text-gray-300 mb-4">Credenciais de acesso</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_email">E-mail</label>
|
||||
<input class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
|
||||
focus:ring-[#f97316] transition-colors" placeholder="usuario@gade.com" type="email" value="" name="user[email]" id="user_email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_password">Senha</label>
|
||||
<input class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
|
||||
focus:ring-[#f97316] transition-colors" placeholder="••••••••" type="password" name="user[password]" id="user_password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_password_confirmation">Confirmar senha</label>
|
||||
<input class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
|
||||
focus:ring-[#f97316] transition-colors" placeholder="••••••••" type="password" name="user[password_confirmation]" id="user_password_confirmation" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fields-pin" class="hidden border-t border-white/5 pt-6">
|
||||
<p class="text-sm font-medium text-gray-300 mb-4">PIN de acesso</p>
|
||||
<div class="max-w-xs">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_pin_code">PIN (4 dígitos)</label>
|
||||
<input maxlength="4" pattern="\d{4}" inputmode="numeric" class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
|
||||
focus:ring-[#f97316] transition-colors text-center text-2xl tracking-[0.5em] font-bold" placeholder="0000" size="4" type="text" name="user[pin_code]" id="user_pin_code" />
|
||||
<p class="text-gray-500 text-xs mt-1.5">Deve ser único entre todos os motoristas</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-white/5">
|
||||
<a class="px-6 py-3 text-gray-400 hover:text-white border border-white/10
|
||||
hover:border-white/20 rounded-xl transition-colors" href="/admin/usuarios">Cancelar</a>
|
||||
<input type="submit" name="commit" value="Criar Usuário" class="px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold
|
||||
rounded-xl transition-colors cursor-pointer min-h-[48px]" data-disable-with="Criar Usuário" />
|
||||
</div>
|
||||
</form></div>
|
||||
|
||||
<script>
|
||||
// IIFE: evita "Identifier already declared" quando o Turbo re-executa o script
|
||||
// ao navegar (o que antes abortava o toggle e escondia o campo de PIN).
|
||||
(function () {
|
||||
const roleSelect = document.querySelector('select[name="user[role]"]');
|
||||
const fieldsEmail = document.getElementById('fields-email');
|
||||
const fieldsPin = document.getElementById('fields-pin');
|
||||
if (!roleSelect || !fieldsEmail || !fieldsPin) return;
|
||||
|
||||
function toggleFields() {
|
||||
const motorista = roleSelect.value === 'motorista';
|
||||
fieldsEmail.classList.toggle('hidden', motorista);
|
||||
fieldsPin.classList.toggle('hidden', !motorista);
|
||||
}
|
||||
|
||||
roleSelect.addEventListener('change', toggleFields);
|
||||
toggleFields(); // estado inicial
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
['flash-notice', 'flash-alert'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.transition = 'opacity 0.5s', el.style.opacity = '0',
|
||||
setTimeout(() => el.remove(), 500);
|
||||
});
|
||||
}, 4000);
|
||||
</script>
|
||||
|
||||
<script async nonce="" type="text/javascript" id="mini-profiler" src="/mini-profiler-resources/includes.js?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-css-url="/mini-profiler-resources/includes.css?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-version="e0bcc9ce0ae3bb5d6b736b6f282f601f" data-path="/mini-profiler-resources/" data-current-id="1ahgpnmdas9x6acftfbr" data-ids="1ahgpnmdas9x6acftfbr,ho1l1mkabtvdcaskpjm7" data-horizontal-position="left" data-vertical-position="top" data-trivial="false" data-children="false" data-max-traces="20" data-controls="false" data-total-sql-count="false" data-authorized="true" data-toggle-shortcut="alt+p" data-start-hidden="false" data-collapse-results="true" data-html-container="body" data-hidden-custom-fields="" data-turbo-permanent="false"></script>
|
||||
</body>
|
||||
</html>
|
||||
2280
Erros/holerite_carlos-matheus-pimentel_10-1.pdf
Normal file
2280
Erros/holerite_carlos-matheus-pimentel_10-1.pdf
Normal file
File diff suppressed because it is too large
Load Diff
3911
Erros/relatorio_bruno-corgozinho_4-2.pdf
Normal file
3911
Erros/relatorio_bruno-corgozinho_4-2.pdf
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-param" content="authenticity_token">
|
||||
<meta name="csrf-token" content="V0huiUnnR6iuZZliHnfydw0_28pJd5ho66yiE1jO186V5ffx6xm7oL5TuqTEwVv9v-9pxKKkTDbXwLQRAdLL3w">
|
||||
<title>teste | Reem Logística</title>
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
preto: '#0a0a0a',
|
||||
'preto-card': '#1a1a1a',
|
||||
laranja: '#f97316',
|
||||
'laranja-escuro': '#ea580c',
|
||||
branco: '#ffffff',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
/* Scrollbar tema escuro */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0a0a0a; }
|
||||
::-webkit-scrollbar-thumb { background: #f97316; border-radius: 3px; }
|
||||
/* Loading spinner laranja */
|
||||
.spinner {
|
||||
border: 3px solid #1a1a1a;
|
||||
border-top-color: #f97316;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<script type="importmap" data-turbo-track="reload">{
|
||||
"imports": {
|
||||
"application": "/assets/application-09caced1.js",
|
||||
"@hotwired/turbo-rails": "/assets/turbo.min-86bf8853.js",
|
||||
"@hotwired/stimulus": "/assets/stimulus.min-7ea3d58b.js",
|
||||
"@hotwired/stimulus-loading": "/assets/stimulus-loading-25917588.js",
|
||||
"controllers/application": "/assets/controllers/application-75a69c37.js",
|
||||
"controllers": "/assets/controllers/index-dfefe98d.js",
|
||||
"controllers/validacao_controller": "/assets/controllers/validacao_controller-3ce614b7.js"
|
||||
}
|
||||
}</script>
|
||||
<link rel="modulepreload" href="/assets/application-09caced1.js">
|
||||
<link rel="modulepreload" href="/assets/turbo.min-86bf8853.js">
|
||||
<link rel="modulepreload" href="/assets/stimulus.min-7ea3d58b.js">
|
||||
<link rel="modulepreload" href="/assets/stimulus-loading-25917588.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/application-75a69c37.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/index-dfefe98d.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/validacao_controller-3ce614b7.js">
|
||||
<script type="module">import "application"</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="bg-[#0a0a0a] text-white min-h-screen">
|
||||
|
||||
|
||||
<div id="sidebar-overlay"
|
||||
class="fixed inset-0 bg-black/70 z-30 hidden md:hidden"
|
||||
onclick="toggleSidebar()"></div>
|
||||
|
||||
<aside id="sidebar"
|
||||
class="fixed top-0 left-0 h-full w-64 bg-[#111111] border-r border-[#2a2a2a] z-40
|
||||
transform -translate-x-full md:translate-x-0 transition-transform duration-300">
|
||||
|
||||
<div class="flex items-center gap-3 px-6 py-5 border-b border-[#2a2a2a]">
|
||||
<div class="w-9 h-9 bg-orange-500 rounded-lg flex items-center justify-center font-black text-black text-lg">G</div>
|
||||
<div>
|
||||
<p class="font-bold text-white text-sm leading-tight">Reem Transporte</p>
|
||||
<p class="text-orange-500 text-xs">Logística</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-b border-[#2a2a2a]">
|
||||
<p class="text-xs text-gray-500 mb-1">Logado como</p>
|
||||
<p class="text-white font-semibold text-sm truncate">Administrador Gade</p>
|
||||
<span class="inline-block mt-1 px-2 py-0.5 text-xs rounded-full
|
||||
bg-orange-500 text-black">
|
||||
Administrador
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
|
||||
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/dashboard">📊 Dashboard</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/consolidacoes">📦 Consolidações</a>
|
||||
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
|
||||
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/usuarios">👥 Usuários</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/configuracoes">⚙️ Configurações</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/auditoria_logs">🔍 Auditoria</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="px-6 py-4 border-t border-[#2a2a2a]">
|
||||
<a data-turbo-method="delete" data-turbo-confirm="Deseja sair?" class="flex items-center gap-2 text-gray-400 hover:text-red-400 text-sm transition-colors" href="/auth/logout">
|
||||
<span>🚪</span> Sair
|
||||
</a> </div>
|
||||
</aside>
|
||||
|
||||
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
|
||||
<button onclick="toggleSidebar()"
|
||||
class="text-orange-500 text-2xl min-h-[48px] min-w-[48px] flex items-center justify-center">
|
||||
☰
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center font-black text-black text-sm">G</div>
|
||||
<span class="font-bold text-white text-sm">Reem Logística</span>
|
||||
</div>
|
||||
<div class="w-12"></div>
|
||||
</header>
|
||||
<div class="h-14 md:hidden"></div>
|
||||
|
||||
<script>
|
||||
function toggleSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
const o = document.getElementById('sidebar-overlay');
|
||||
s.classList.toggle('-translate-x-full');
|
||||
o.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
<main class="ml-0 md:ml-64 p-4 md:p-8 min-h-screen">
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
|
||||
<div class="mb-6">
|
||||
<a class="text-gray-500 hover:text-orange-500 text-sm" href="/consolidacoes">← Consolidações</a>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mt-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-white">teste</h1>
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-yellow-500 text-black">📝 Rascunho</span>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
📅 01/02/2026 → 28/02/2026
|
||||
· Criada por Administrador Gade
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a class="bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center" href="/consolidacoes/3/wizard">✏️ Continuar validação</a>
|
||||
<form class="button_to" method="post" action="/consolidacoes/3/arquivar"><button data-turbo-confirm="Arquivar esta consolidação? Ela não será excluída, apenas movida para o arquivo." class="text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px]" type="submit">📁 Arquivar</button><input type="hidden" name="authenticity_token" value="bFgW-DhUfHVabQb6YBBZhcI6kTfr6RY-m2AcIg74Too_85JbSI2x1yKmq7VymjoA3QnfDb2v-F5VSGRHh8Q_DQ" autocomplete="off" /></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1a1a1a] border border-orange-500 rounded-xl p-6 mb-6 text-center">
|
||||
<p class="text-gray-400 text-sm">Valor total da consolidação</p>
|
||||
<p class="text-orange-500 font-black text-4xl">R$ 5265,00</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-white font-bold text-lg mb-3">👥 Motoristas</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-white font-bold text-lg">Bruno Corgozinho</p>
|
||||
<p class="text-orange-500 font-black text-xl">R$ 5265,00</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button onclick="abrirPreview('/consolidacoes/3/preview_holerite?motorista=Bruno+Corgozinho')"
|
||||
class="bg-[#0a0a0a] hover:bg-[#2a2a2a] text-white border border-orange-500 font-semibold px-4 py-3 rounded-lg text-sm min-h-[48px]">
|
||||
👁️ Ver antes de gerar
|
||||
</button>
|
||||
<a class="bg-orange-800 hover:bg-orange-700 text-white font-semibold px-4 py-3 rounded-lg text-sm min-h-[48px] flex items-center" href="/consolidacoes/3/gerar_pdf_relatorio?motorista=Bruno+Corgozinho">📄 Relatório Individual</a>
|
||||
<a class="bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 py-3 rounded-lg text-sm min-h-[48px] flex items-center" href="/consolidacoes/3/gerar_pdf_holerite?motorista=Bruno+Corgozinho">🧾 Gerar Holerite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-preview" class="fixed inset-0 bg-black/80 z-50 hidden items-center justify-center p-4">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl max-w-2xl w-full max-h-[85vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-[#2a2a2a]">
|
||||
<h3 class="text-white font-bold">👁️ Pré-visualização do Holerite</h3>
|
||||
<button onclick="fecharPreview()" class="text-gray-400 hover:text-white text-2xl min-w-[44px] min-h-[44px]">✕</button>
|
||||
</div>
|
||||
<div id="modal-preview-conteudo" class="overflow-y-auto p-6 flex-1">
|
||||
<div class="flex justify-center py-10"><div class="spinner w-10 h-10"></div></div>
|
||||
</div>
|
||||
<div class="flex gap-3 px-6 py-4 border-t border-[#2a2a2a]">
|
||||
<a id="modal-confirmar" href="#"
|
||||
class="flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold py-3 rounded-lg text-center min-h-[48px] flex items-center justify-center">
|
||||
✓ Confirmar e baixar PDF
|
||||
</a>
|
||||
<button onclick="fecharPreview()"
|
||||
class="px-6 py-3 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg min-h-[48px]">
|
||||
← Voltar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function abrirPreview(url) {
|
||||
const modal = document.getElementById('modal-preview');
|
||||
const conteudo = document.getElementById('modal-preview-conteudo');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
conteudo.innerHTML = '<div class="flex justify-center py-10"><div class="spinner w-10 h-10"></div></div>';
|
||||
|
||||
const resp = await fetch(url);
|
||||
conteudo.innerHTML = await resp.text();
|
||||
|
||||
// O link de confirmar aponta para o PDF do mesmo motorista
|
||||
document.getElementById('modal-confirmar').href =
|
||||
url.replace('preview_holerite', 'gerar_pdf_holerite');
|
||||
}
|
||||
function fecharPreview() {
|
||||
const modal = document.getElementById('modal-preview');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
</script>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
['flash-notice', 'flash-alert'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.transition = 'opacity 0.5s', el.style.opacity = '0',
|
||||
setTimeout(() => el.remove(), 500);
|
||||
});
|
||||
}, 4000);
|
||||
</script>
|
||||
|
||||
<script async nonce="" type="text/javascript" id="mini-profiler" src="/mini-profiler-resources/includes.js?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-css-url="/mini-profiler-resources/includes.css?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-version="e0bcc9ce0ae3bb5d6b736b6f282f601f" data-path="/mini-profiler-resources/" data-current-id="5vfdctp5mnyu0tdtx94a" data-ids="5vfdctp5mnyu0tdtx94a,md2esz0jc4j57ce236e8" data-horizontal-position="left" data-vertical-position="top" data-trivial="false" data-children="false" data-max-traces="20" data-controls="false" data-total-sql-count="false" data-authorized="true" data-toggle-shortcut="alt+p" data-start-hidden="false" data-collapse-results="true" data-html-container="body" data-hidden-custom-fields="" data-turbo-permanent="false"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
Gemfile
9
Gemfile
@@ -29,11 +29,6 @@ gem "prawn-table"
|
||||
|
||||
# Planilhas (XLSX) — geração da carga de importação do SimpliRoute
|
||||
gem "caxlsx"
|
||||
# Leitura do .xlsx do PLANO do SimpliRoute (reserva do romaneio, quando a API não
|
||||
# responde ou o plano do dia ainda não foi publicado). A roo pina rubyzip < 3.0;
|
||||
# o único outro consumidor é o selenium-webdriver (grupo de teste, aceita >= 1.2.2,
|
||||
# < 4.0), então o bundler rebaixa o rubyzip sem quebrar nada.
|
||||
gem "roo", "~> 2.10"
|
||||
gem "rqrcode" # QR code no holerite (login rápido motorista)
|
||||
gem "chunky_png" # renderiza QR como PNG para embed no Prawn
|
||||
|
||||
@@ -52,10 +47,6 @@ group :development, :test do
|
||||
gem "rspec-rails"
|
||||
gem "factory_bot_rails"
|
||||
gem "faker"
|
||||
# Lê o PDF gerado (páginas e texto) nos specs. Sem ele, o único jeito de testar
|
||||
# um PDF do Prawn seria olhar o arquivo: as fontes TTF são subsetadas, então o
|
||||
# texto não é "grepável" no binário.
|
||||
gem "pdf-inspector", require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
24
Gemfile.lock
24
Gemfile.lock
@@ -1,7 +1,6 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
Ascii85 (2.0.1)
|
||||
actioncable (7.1.6)
|
||||
actionpack (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
@@ -83,7 +82,6 @@ GEM
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
afm (1.0.0)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
@@ -102,11 +100,6 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
caxlsx (4.5.0)
|
||||
htmlentities (~> 4.3, >= 4.3.4)
|
||||
marcel (~> 1.0)
|
||||
nokogiri (~> 1.10, >= 1.10.4)
|
||||
rubyzip (>= 2.4, < 4)
|
||||
cgi (0.5.1)
|
||||
chronic (0.10.2)
|
||||
chunky_png (1.4.0)
|
||||
@@ -146,8 +139,6 @@ GEM
|
||||
net-http (~> 0.5)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
hashery (2.1.2)
|
||||
htmlentities (4.4.2)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (2.0.2)
|
||||
@@ -201,13 +192,6 @@ GEM
|
||||
orm_adapter (0.5.0)
|
||||
pagy (9.4.0)
|
||||
pdf-core (0.10.0)
|
||||
pdf-inspector (1.3.0)
|
||||
pdf-reader (>= 1.0, < 3.0.a)
|
||||
pdf-reader (2.16.0)
|
||||
Ascii85 (>= 1.0, < 3.0, != 2.0.0)
|
||||
afm (>= 0.2.1, < 2)
|
||||
hashery (~> 2.0)
|
||||
ttfunk
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
@@ -285,9 +269,6 @@ GEM
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rexml (3.4.4)
|
||||
roo (2.10.1)
|
||||
nokogiri (~> 1)
|
||||
rubyzip (>= 1.3.0, < 3.0.0)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
@@ -309,7 +290,7 @@ GEM
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.7)
|
||||
rubyzip (2.4.1)
|
||||
rubyzip (3.4.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.44.0)
|
||||
base64 (~> 0.2)
|
||||
@@ -363,7 +344,6 @@ PLATFORMS
|
||||
DEPENDENCIES
|
||||
annotate
|
||||
capybara
|
||||
caxlsx
|
||||
chunky_png
|
||||
debug
|
||||
devise
|
||||
@@ -374,7 +354,6 @@ DEPENDENCIES
|
||||
importmap-rails
|
||||
jbuilder
|
||||
pagy (~> 9.0)
|
||||
pdf-inspector
|
||||
pg (~> 1.1)
|
||||
prawn
|
||||
prawn-table
|
||||
@@ -383,7 +362,6 @@ DEPENDENCIES
|
||||
pundit
|
||||
rack-mini-profiler
|
||||
rails (~> 7.1.0)
|
||||
roo (~> 2.10)
|
||||
rqrcode
|
||||
rspec-rails
|
||||
selenium-webdriver
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/* app/assets/tailwind/application.css
|
||||
*
|
||||
* ESTE ARQUIVO EXISTE PARA O DEPLOY NÃO QUEBRAR. Leia antes de apagar.
|
||||
*
|
||||
* A gem `tailwindcss-rails` está no Gemfile e pendura a tarefa `tailwindcss:build`
|
||||
* dentro de `assets:precompile`. Ela procura ESTE caminho fixo e, se não achar,
|
||||
* aborta com:
|
||||
*
|
||||
* Specified input file `./app/assets/tailwind/application.css` does not exist.
|
||||
* Tasks: TOP => assets:precompile => tailwindcss:build
|
||||
*
|
||||
* E o `assets:precompile` morre INTEIRO nessa linha — nenhum arquivo chega em
|
||||
* public/assets. O efeito na tela é traiçoeiro: o Propshaft, sem manifesto, cai
|
||||
* no resolvedor dinâmico e continua montando URLs com o digest certo, então a
|
||||
* página abre normal e CADA /assets/*.js devolve 404 em silêncio. O sistema fica
|
||||
* sem JavaScript nenhum: prévia do PDF, edição inline da tabela, busca e
|
||||
* carrossel todos mortos, sem erro visível. Foi o que aconteceu de 27 a
|
||||
* 28/08/2026 e custou três rodadas de diagnóstico.
|
||||
*
|
||||
* POR QUE ESTÁ VAZIO: o CSS do sistema hoje vem do CDN do Tailwind, carregado em
|
||||
* app/views/layouts/application.html.erb. Sem `@import "tailwindcss"` aqui, o
|
||||
* build termina na hora e gera um arquivo vazio — nada é servido a mais e o
|
||||
* deploy não perde tempo montando um CSS que ninguém carrega.
|
||||
*
|
||||
* DÍVIDA CONHECIDA (uma das duas, quando houver espaço):
|
||||
* a) migrar do CDN para o Tailwind local — o próprio Tailwind desaconselha o
|
||||
* CDN em produção, e ele deixa o sistema dependendo de rede externa. Neste
|
||||
* caso, troque este comentário por `@import "tailwindcss";` e o
|
||||
* <script src="https://cdn.tailwindcss.com"> por stylesheet_link_tag; ou
|
||||
* b) remover `tailwindcss-rails` do Gemfile, que hoje é dependência morta.
|
||||
* Aí este arquivo some junto.
|
||||
*/
|
||||
@@ -1,10 +1,7 @@
|
||||
# app/controllers/admin/auditoria_logs_controller.rb
|
||||
class Admin::AuditoriaLogsController < ApplicationController
|
||||
def index
|
||||
# Policy própria (AuditoriaLogPolicy). Com `policy_class: ApplicationPolicy`
|
||||
# a régua era a de consolidação: quem podia consolidar abria a auditoria pela
|
||||
# URL, mesmo sem o item no menu.
|
||||
authorize AuditoriaLog
|
||||
authorize AuditoriaLog, policy_class: ApplicationPolicy
|
||||
|
||||
@logs = AuditoriaLog.recentes.limit(200)
|
||||
@logs = @logs.por_user(params[:user_id]) if params[:user_id].present?
|
||||
|
||||
@@ -9,9 +9,7 @@ class Admin::ConfiguracaoNotificacoesController < ApplicationController
|
||||
smtp_ativo smtp_address smtp_port smtp_username smtp_password smtp_domain
|
||||
smtp_autenticacao remetente_email remetente_nome
|
||||
email_notificacoes_ativo email_admin
|
||||
whatsapp_ativo whatsapp_provedor whatsapp_intervalo_segundos whatsapp_admin
|
||||
twilio_account_sid twilio_auth_token twilio_from
|
||||
baileys_url baileys_token
|
||||
whatsapp_ativo twilio_account_sid twilio_auth_token twilio_from whatsapp_admin
|
||||
].freeze
|
||||
|
||||
def show
|
||||
@@ -62,7 +60,6 @@ class Admin::ConfiguracaoNotificacoesController < ApplicationController
|
||||
# tela nunca exibe o valor, então salvar sem redigitar não pode apagá-lo.
|
||||
permitidos.delete(:smtp_password) if permitidos[:smtp_password].blank?
|
||||
permitidos.delete(:twilio_auth_token) if permitidos[:twilio_auth_token].blank?
|
||||
permitidos.delete(:baileys_token) if permitidos[:baileys_token].blank?
|
||||
permitidos
|
||||
end
|
||||
|
||||
@@ -82,15 +79,11 @@ class Admin::ConfiguracaoNotificacoesController < ApplicationController
|
||||
email_admin: @config.email_admin,
|
||||
email_notificacoes_ativo: @config.email_notificacoes_ativo,
|
||||
whatsapp_ativo: @config.whatsapp_ativo,
|
||||
whatsapp_provedor: @config.whatsapp_provedor,
|
||||
whatsapp_intervalo: @config.whatsapp_intervalo_segundos,
|
||||
twilio_account_sid: @config.twilio_account_sid,
|
||||
twilio_from: @config.twilio_from,
|
||||
whatsapp_admin: @config.whatsapp_admin,
|
||||
baileys_url: @config.baileys_url,
|
||||
smtp_password_definida: @config.smtp_password?,
|
||||
twilio_auth_token_definido: @config.twilio_auth_token?,
|
||||
baileys_token_definido: @config.baileys_token?
|
||||
twilio_auth_token_definido: @config.twilio_auth_token?
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# app/controllers/admin/contatos_controller.rb
|
||||
#
|
||||
# Cadastro manual de quem recebe as mensagens: nome, WhatsApp e/ou e-mail, e o
|
||||
# grupo. O telefone é normalizado para E.164 no model — o ADM digita como quiser.
|
||||
class Admin::ContatosController < ApplicationController
|
||||
before_action :set_contato, only: %i[edit update destroy]
|
||||
before_action :carregar_grupos, only: %i[new create edit update]
|
||||
|
||||
def index
|
||||
authorize Contato
|
||||
@grupos = GrupoContato.ordenados
|
||||
escopo = policy_scope(Contato).includes(:grupo_contato)
|
||||
escopo = escopo.where(grupo_contato_id: params[:grupo_id]) if params[:grupo_id].present?
|
||||
escopo = escopo.where('LOWER(nome) LIKE :q OR telefone LIKE :q OR LOWER(email) LIKE :q',
|
||||
q: "%#{params[:q].to_s.strip.downcase}%") if params[:q].present?
|
||||
@contatos = escopo.ordenados
|
||||
end
|
||||
|
||||
# GET /admin/contatos/grupos_whatsapp (JSON)
|
||||
def grupos_whatsapp
|
||||
authorize Contato, :create?
|
||||
|
||||
resposta = Notificacao::ClienteWhatsapp.padrao.grupos
|
||||
if resposta.ok?
|
||||
render json: { grupos: resposta.dados['grupos'] || [] }
|
||||
else
|
||||
render json: { erro: resposta.erro, grupos: [] }, status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
@contato = Contato.new(grupo_contato_id: params[:grupo_id], tipo: params[:tipo].presence || 'pessoa')
|
||||
authorize @contato
|
||||
end
|
||||
|
||||
def create
|
||||
@contato = Contato.new(contato_params)
|
||||
authorize @contato
|
||||
|
||||
if @contato.save
|
||||
registrar("Cadastrou contato #{@contato.nome}")
|
||||
redirect_to admin_contatos_path, notice: 'Contato cadastrado.'
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @contato
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contato
|
||||
|
||||
if @contato.update(contato_params)
|
||||
registrar("Editou contato #{@contato.nome}")
|
||||
redirect_to admin_contatos_path, notice: 'Contato atualizado.'
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contato
|
||||
nome = @contato.nome
|
||||
@contato.destroy
|
||||
registrar("Excluiu contato #{nome}")
|
||||
redirect_to admin_contatos_path, notice: 'Contato removido.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_contato
|
||||
@contato = Contato.find(params[:id])
|
||||
end
|
||||
|
||||
def carregar_grupos
|
||||
@grupos = GrupoContato.ativos.ordenados
|
||||
end
|
||||
|
||||
def contato_params
|
||||
params.require(:contato).permit(:nome, :telefone, :email, :grupo_contato_id, :ativo,
|
||||
:observacao, :tipo, :whatsapp_grupo_jid, :whatsapp_grupo_nome)
|
||||
end
|
||||
|
||||
def registrar(detalhes)
|
||||
AuditoriaLog.registrar(user: current_user, acao: 'contato', entidade: 'Contato',
|
||||
dados_novos: { detalhes: detalhes }, request: request)
|
||||
end
|
||||
end
|
||||
@@ -118,15 +118,6 @@ class Admin::EdicaoLancamentosController < ApplicationController
|
||||
request: request
|
||||
)
|
||||
|
||||
# Mudança na operação feita POR DENTRO do sistema. A maioria das mudanças
|
||||
# vem de fora e é pega pelo DetectarMudancasOperacaoJob; esta chega na hora
|
||||
# e sabe exatamente qual NF e o que mudou.
|
||||
Notificacao::Gatilhos.operacao_alterada(
|
||||
operacao: anterior['title'].presence || 'SimpliRoute',
|
||||
o_que_mudou: descrever_mudanca(anterior, atualizada, attrs.keys),
|
||||
nf: anterior['reference']
|
||||
)
|
||||
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
render json: { ok: true, campos: attrs.keys, visita: atualizada.slice(*CAMPOS_EDITAVEIS) }
|
||||
@@ -166,18 +157,6 @@ class Admin::EdicaoLancamentosController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
# "status: pending → completed; observação: … → ÓBITO" — o texto que vai para
|
||||
# a variável {{o_que_mudou}} do editor de blocos.
|
||||
def descrever_mudanca(anterior, atualizada, campos)
|
||||
Array(campos).filter_map do |campo|
|
||||
de = anterior[campo].to_s.strip
|
||||
para = atualizada[campo].to_s.strip
|
||||
next if de == para
|
||||
|
||||
"#{campo}: #{de.presence || '(vazio)'} → #{para.presence || '(vazio)'}"
|
||||
end.join('; ').presence || 'lançamento atualizado'
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= SimpliRoute::Client.new
|
||||
end
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# app/controllers/admin/eventos_notificacao_controller.rb
|
||||
#
|
||||
# Cadastro dos eventos e de quais grupos assinam cada um.
|
||||
#
|
||||
# O ADM cria quantos eventos quiser, mas o GATILHO sai de EventoNotificacao::
|
||||
# GATILHOS — gatilho é código. `manual` cobre o aviso pontual (o texto é
|
||||
# digitado na hora do disparo) e `agendado` cobre o resumo periódico.
|
||||
class Admin::EventosNotificacaoController < ApplicationController
|
||||
before_action :set_evento, only: %i[edit update destroy disparar]
|
||||
before_action :carregar_grupos, only: %i[new create edit update]
|
||||
|
||||
def index
|
||||
authorize EventoNotificacao
|
||||
@eventos = policy_scope(EventoNotificacao)
|
||||
.ordenados.includes(assinaturas: :grupo_contato)
|
||||
end
|
||||
|
||||
def new
|
||||
@evento = EventoNotificacao.new(gatilho: 'manual', ativo: true, hora: 8)
|
||||
authorize @evento
|
||||
montar_assinaturas
|
||||
end
|
||||
|
||||
def create
|
||||
@evento = EventoNotificacao.new(evento_params)
|
||||
authorize @evento
|
||||
|
||||
if @evento.save
|
||||
salvar_assinaturas
|
||||
registrar("Criou evento de notificação #{@evento.nome}")
|
||||
redirect_to admin_eventos_path, notice: 'Evento criado.'
|
||||
else
|
||||
montar_assinaturas
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @evento
|
||||
montar_assinaturas
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @evento
|
||||
|
||||
# `chave` é o que o código usa para achar o evento no disparo. Deixar o ADM
|
||||
# renomear a chave de um evento de sistema quebraria o gatilho em silêncio.
|
||||
permitidos = @evento.sistema? ? evento_params.except(:chave, :gatilho) : evento_params
|
||||
|
||||
if @evento.update(permitidos)
|
||||
salvar_assinaturas
|
||||
registrar("Editou evento de notificação #{@evento.nome}")
|
||||
redirect_to admin_eventos_path, notice: 'Evento atualizado.'
|
||||
else
|
||||
montar_assinaturas
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @evento
|
||||
nome = @evento.nome
|
||||
@evento.destroy
|
||||
registrar("Excluiu evento de notificação #{nome}")
|
||||
redirect_to admin_eventos_path, notice: 'Evento removido.'
|
||||
end
|
||||
|
||||
# Disparo manual: manda mensagem DE VERDADE para todos os grupos assinantes.
|
||||
# O texto é digitado na hora — o editor de blocos ainda não existe.
|
||||
def disparar
|
||||
authorize @evento, :disparar?
|
||||
|
||||
corpo = params[:corpo].to_s.strip
|
||||
# Com mensagem montada no editor o texto é opcional: o template vence e o
|
||||
# corpo digitado só serve de reserva para o canal que ainda não tem um.
|
||||
com_template = MensagemTemplate::CANAIS.any? { |c| @evento.template_utilizavel(c) }
|
||||
if corpo.blank? && !com_template
|
||||
return redirect_to(admin_eventos_path, alert: 'Escreva a mensagem ou monte o template antes de disparar.')
|
||||
end
|
||||
|
||||
Notificacao::Despachante.disparar(chave: @evento.chave,
|
||||
dados: Notificacao::Variaveis.comuns_reais,
|
||||
assunto: params[:assunto].presence || @evento.nome,
|
||||
corpo: corpo)
|
||||
registrar("Disparou manualmente o evento #{@evento.nome}")
|
||||
redirect_to admin_envios_path,
|
||||
notice: 'Disparo agendado. Acompanhe o resultado aqui.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_evento
|
||||
@evento = EventoNotificacao.find(params[:id])
|
||||
end
|
||||
|
||||
def carregar_grupos
|
||||
@grupos = GrupoContato.ativos.ordenados
|
||||
end
|
||||
|
||||
# Uma linha por grupo ativo, marcada quando já existe assinatura. É o que faz
|
||||
# a tela mostrar TODOS os grupos com checkbox, e não só os já assinados.
|
||||
def montar_assinaturas
|
||||
existentes = @evento.assinaturas.index_by(&:grupo_contato_id)
|
||||
@linhas_assinatura = @grupos.map do |grupo|
|
||||
{ grupo: grupo, assinatura: existentes[grupo.id] }
|
||||
end
|
||||
end
|
||||
|
||||
def salvar_assinaturas
|
||||
# ActionController::Parameters, indexado pela id do grupo como STRING (é
|
||||
# assim que o form serializa: assinaturas[12][ativo]). Nada de `to_h` aqui:
|
||||
# em params não permitidos ele levanta.
|
||||
marcadas = params.fetch(:assinaturas, {})
|
||||
|
||||
GrupoContato.ativos.find_each do |grupo|
|
||||
dados = marcadas[grupo.id.to_s]
|
||||
atual = @evento.assinaturas.find_by(grupo_contato_id: grupo.id)
|
||||
|
||||
if dados.present? && dados[:ativo] == '1'
|
||||
canal = GrupoEventoAssinatura::CANAIS.include?(dados[:canal]) ? dados[:canal] : 'ambos'
|
||||
atual ? atual.update(canal: canal, ativo: true)
|
||||
: @evento.assinaturas.create(grupo_contato: grupo, canal: canal, ativo: true)
|
||||
elsif atual
|
||||
atual.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def evento_params
|
||||
params.require(:evento_notificacao)
|
||||
.permit(:nome, :chave, :gatilho, :descricao, :ativo, :notificar_envolvido,
|
||||
:frequencia, :hora, :dia_semana)
|
||||
end
|
||||
|
||||
def registrar(detalhes)
|
||||
AuditoriaLog.registrar(user: current_user, acao: 'evento_notificacao',
|
||||
entidade: 'EventoNotificacao',
|
||||
dados_novos: { detalhes: detalhes }, request: request)
|
||||
end
|
||||
end
|
||||
@@ -1,74 +0,0 @@
|
||||
# app/controllers/admin/grupos_contato_controller.rb
|
||||
#
|
||||
# Grupos de destinatários. É o grupo que assina os eventos, então cadastrar
|
||||
# alguém novo é escolher o grupo — não repetir a configuração pessoa por pessoa.
|
||||
class Admin::GruposContatoController < ApplicationController
|
||||
before_action :set_grupo, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
authorize GrupoContato
|
||||
@grupos = policy_scope(GrupoContato).ordenados
|
||||
.includes(:contatos, assinaturas: :evento_notificacao)
|
||||
end
|
||||
|
||||
def new
|
||||
@grupo = GrupoContato.new
|
||||
authorize @grupo
|
||||
end
|
||||
|
||||
def create
|
||||
@grupo = GrupoContato.new(grupo_params)
|
||||
authorize @grupo
|
||||
|
||||
if @grupo.save
|
||||
registrar("Criou grupo de contatos #{@grupo.nome}")
|
||||
redirect_to admin_grupos_path, notice: 'Grupo criado.'
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @grupo
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @grupo
|
||||
|
||||
if @grupo.update(grupo_params)
|
||||
registrar("Editou grupo de contatos #{@grupo.nome}")
|
||||
redirect_to admin_grupos_path, notice: 'Grupo atualizado.'
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @grupo
|
||||
|
||||
# `dependent: :nullify`: os contatos NÃO são apagados junto — ficam sem
|
||||
# grupo e param de receber, o que é reversível. Apagar pessoas porque o
|
||||
# grupo saiu seria destrutivo demais para um clique.
|
||||
nome = @grupo.nome
|
||||
orfaos = @grupo.contatos.count
|
||||
@grupo.destroy
|
||||
registrar("Excluiu grupo de contatos #{nome} (#{orfaos} contato(s) ficaram sem grupo)")
|
||||
redirect_to admin_grupos_path,
|
||||
notice: "Grupo removido. #{orfaos} contato(s) ficaram sem grupo e não recebem mais."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_grupo
|
||||
@grupo = GrupoContato.find(params[:id])
|
||||
end
|
||||
|
||||
def grupo_params
|
||||
params.require(:grupo_contato).permit(:nome, :descricao, :ativo)
|
||||
end
|
||||
|
||||
def registrar(detalhes)
|
||||
AuditoriaLog.registrar(user: current_user, acao: 'grupo_contato', entidade: 'GrupoContato',
|
||||
dados_novos: { detalhes: detalhes }, request: request)
|
||||
end
|
||||
end
|
||||
@@ -1,75 +0,0 @@
|
||||
# app/controllers/admin/mensagem_templates_controller.rb
|
||||
#
|
||||
# Editor de blocos: monta o corpo de UM evento em UM canal.
|
||||
#
|
||||
# O preview roda no SERVIDOR, chamando o mesmo Notificacao::Renderizador do
|
||||
# envio. Uma segunda implementação em JavaScript ficaria mais rápida e
|
||||
# inevitavelmente divergiria do que é enviado de verdade — e o preview existe
|
||||
# justamente para prometer o contrário.
|
||||
class Admin::MensagemTemplatesController < ApplicationController
|
||||
before_action :carregar
|
||||
|
||||
def edit
|
||||
authorize @evento, :update?
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @evento, :update?
|
||||
|
||||
@template.assign_attributes(atributos)
|
||||
if @template.save
|
||||
AuditoriaLog.registrar(user: current_user, acao: 'mensagem_template',
|
||||
entidade: 'MensagemTemplate',
|
||||
dados_novos: { detalhes: "Editou a mensagem de #{@evento.nome} (#{@canal})" },
|
||||
request: request)
|
||||
redirect_to template_admin_evento_path(@evento, canal: @canal), notice: 'Mensagem salva.'
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Renderiza o que está NA TELA (ainda não salvo) com os valores de amostra.
|
||||
def preview
|
||||
authorize @evento, :update?
|
||||
|
||||
provisorio = MensagemTemplate.new(evento_notificacao: @evento, canal: @canal,
|
||||
assunto: params[:assunto], blocos: blocos_do_form, ativo: true)
|
||||
provisorio.validate # dispara a limpeza dos blocos antes de renderizar
|
||||
|
||||
dados = Notificacao::CatalogoVariaveis.amostra(@evento.gatilho)
|
||||
render json: { html: corpo_do_preview(provisorio, dados),
|
||||
assunto: provisorio.renderizador(dados).interpolar(provisorio.assunto) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def carregar
|
||||
@evento = EventoNotificacao.find(params[:id])
|
||||
@canal = MensagemTemplate::CANAIS.include?(params[:canal]) ? params[:canal] : 'whatsapp'
|
||||
@template = @evento.template(@canal)
|
||||
@variaveis = @evento.variaveis
|
||||
end
|
||||
|
||||
def atributos
|
||||
{ assunto: params[:assunto].to_s.strip,
|
||||
blocos: blocos_do_form,
|
||||
ativo: params[:ativo] == '1' }
|
||||
end
|
||||
|
||||
# O editor serializa o estado num campo hidden. JSON inválido vira lista
|
||||
# vazia: salvar lixo seria pior do que salvar nada, e o model ainda descarta
|
||||
# bloco fora do catálogo.
|
||||
def blocos_do_form
|
||||
JSON.parse(params[:blocos].to_s)
|
||||
rescue JSON::ParserError
|
||||
[]
|
||||
end
|
||||
|
||||
# E-mail: HTML já montado e escapado pelo renderizador — vai para a tela como
|
||||
# está, dentro de um iframe sandbox na view. WhatsApp: texto puro, que a view
|
||||
# escapa.
|
||||
def corpo_do_preview(template, dados)
|
||||
r = template.renderizador(dados)
|
||||
template.email? ? r.html : r.texto
|
||||
end
|
||||
end
|
||||
@@ -1,23 +0,0 @@
|
||||
# app/controllers/admin/notificacao_envios_controller.rb
|
||||
#
|
||||
# Log de envios — responde "o contato recebeu?". Sem esta tela, a resposta só
|
||||
# existia no log do Rails, porque o disparo engole a exceção de propósito (um
|
||||
# SMTP fora do ar não pode travar o fechamento de um pagamento).
|
||||
class Admin::NotificacaoEnviosController < ApplicationController
|
||||
POR_PAGINA = 50
|
||||
|
||||
def index
|
||||
authorize NotificacaoEnvio
|
||||
|
||||
escopo = policy_scope(NotificacaoEnvio).includes(:evento_notificacao, :contato, :user)
|
||||
escopo = escopo.where(status: params[:status]) if NotificacaoEnvio::STATUS.include?(params[:status])
|
||||
escopo = escopo.where(canal: params[:canal]) if NotificacaoEnvio::CANAIS.include?(params[:canal])
|
||||
|
||||
@total_falhas = escopo.falhados.count
|
||||
begin
|
||||
@pagy, @envios = pagy(escopo.recentes, limit: POR_PAGINA)
|
||||
rescue Pagy::OverflowError
|
||||
@pagy, @envios = pagy(escopo.recentes, limit: POR_PAGINA, page: 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,110 +0,0 @@
|
||||
# app/controllers/admin/perfis_acesso_controller.rb
|
||||
#
|
||||
# CRUD dos perfis de acesso. É aqui que o ADM decide o que cada função enxerga,
|
||||
# sem depender de deploy: o catálogo do que pode ser marcado é código
|
||||
# (Permissao::TODAS), a escolha é dado (PerfilAcesso#permissoes).
|
||||
module Admin
|
||||
class PerfisAcessoController < ApplicationController
|
||||
include Auditavel
|
||||
|
||||
before_action :set_perfil, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
authorize PerfilAcesso
|
||||
@perfis = policy_scope(PerfilAcesso).ordenados.includes(:users)
|
||||
end
|
||||
|
||||
def new
|
||||
@perfil = PerfilAcesso.new(ativo: true, permissoes: [])
|
||||
authorize @perfil
|
||||
end
|
||||
|
||||
def create
|
||||
@perfil = PerfilAcesso.new(perfil_params)
|
||||
authorize @perfil
|
||||
|
||||
if @perfil.save
|
||||
auditar!(:criar, @perfil, dados_novos: { nome: @perfil.nome, permissoes: @perfil.permissoes })
|
||||
redirect_to admin_perfis_path, notice: "Perfil #{@perfil.nome} criado."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def update
|
||||
anteriores = @perfil.permissoes
|
||||
|
||||
# Ordem importa: aplicamos os valores em memória para a trava conferir o
|
||||
# que o ADM ESTÁ salvando, não o que está no banco.
|
||||
@perfil.assign_attributes(perfil_params)
|
||||
|
||||
if travaria_o_sistema?
|
||||
@perfil.errors.add(:base, mensagem_da_trava)
|
||||
return render(:edit, status: :unprocessable_entity)
|
||||
end
|
||||
|
||||
if @perfil.save
|
||||
auditar!(:editar, @perfil,
|
||||
dados_anteriores: { permissoes: anteriores },
|
||||
dados_novos: { permissoes: @perfil.permissoes, ativo: @perfil.ativo })
|
||||
redirect_to admin_perfis_path, notice: "Perfil #{@perfil.nome} atualizado."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless @perfil.apagavel?
|
||||
return redirect_to(admin_perfis_path, alert: motivo_para_nao_apagar)
|
||||
end
|
||||
|
||||
@perfil.destroy
|
||||
auditar!(:excluir, @perfil, dados_anteriores: { nome: @perfil.nome })
|
||||
redirect_to admin_perfis_path, notice: 'Perfil excluído.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_perfil
|
||||
@perfil = PerfilAcesso.find(params[:id])
|
||||
authorize @perfil
|
||||
end
|
||||
|
||||
def perfil_params
|
||||
dados = params.require(:perfil_acesso).permit(:nome, :descricao, :ativo, permissoes: [])
|
||||
# O form manda um campo vazio para o caso de NENHUMA caixa marcada (senão o
|
||||
# parâmetro some e "desmarcar tudo" viraria "não mexer em nada").
|
||||
dados[:permissoes] = Permissao.filtrar(dados[:permissoes])
|
||||
dados
|
||||
end
|
||||
|
||||
# ── Trava do último administrador ────────────────────────
|
||||
# Mesma regra da tela de usuários (User.administradores_de_acesso): salvar um
|
||||
# perfil que perde `admin.perfis`/`admin.usuarios_gerenciar` (ou é desativado)
|
||||
# não pode deixar o sistema sem quem conserte.
|
||||
def travaria_o_sistema?
|
||||
return false if @perfil.administra_acesso? # continua podendo: nada a checar
|
||||
return false unless @perfil.users.ativos.exists? # perfil vazio não tira acesso de ninguém
|
||||
|
||||
# Quem está NESTE perfil perderá o acesso; sobra alguém fora dele?
|
||||
# Usuário sem perfil conta: cai no padrão do papel (admin legado administra).
|
||||
restantes = User.administradores_de_acesso.reject { |u| u.perfil_acesso_id == @perfil.id }
|
||||
restantes.empty?
|
||||
end
|
||||
|
||||
def mensagem_da_trava
|
||||
'Esta alteração deixaria o sistema sem nenhum usuário ativo capaz de gerenciar ' \
|
||||
'perfis e usuários. Garanta que outra pessoa mantenha esse acesso antes de salvar.'
|
||||
end
|
||||
|
||||
def motivo_para_nao_apagar
|
||||
if @perfil.sistema?
|
||||
'Perfis que vêm com o sistema não podem ser excluídos — desative-o ou edite as permissões.'
|
||||
else
|
||||
"Este perfil está em uso por #{@perfil.users.count} usuário(s). Mova essas pessoas para outro perfil antes de excluir."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,344 +0,0 @@
|
||||
# app/controllers/admin/romaneios_controller.rb
|
||||
#
|
||||
# Romaneio "CONTROLE DE ENTREGA": importa o plano do dia, deixa o operador revisar a
|
||||
# tabela com a prévia do PDF real ao lado, e imprime uma folha A4 paisagem por
|
||||
# veículo.
|
||||
#
|
||||
# As bases externas (espelho de rastreio e gade_entregas_*) continuam SOMENTE
|
||||
# LEITURA — o que gravamos são as tabelas `romaneio*`, nossas.
|
||||
class Admin::RomaneiosController < ApplicationController
|
||||
include Auditavel
|
||||
|
||||
before_action :carregar_romaneio, except: %i[index create importar_planilha]
|
||||
|
||||
def index
|
||||
authorize Romaneio
|
||||
# includes: a listagem mostra a contagem de veículos de cada romaneio.
|
||||
@romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50)
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
@data_padrao = Date.current
|
||||
# Nome ORIGINAL do logo escolhido (a descricao), para a tela mostrar o mesmo
|
||||
# que o operador enviou; nil = ainda usando o logo da GADE do repositório.
|
||||
@logo_atual = Configuracao.find_by(chave: 'romaneio_logo')&.descricao
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @romaneio
|
||||
@veiculos = @romaneio.romaneio_veiculos.ordenados.to_a
|
||||
@veiculo = veiculo_selecionado
|
||||
@linhas = @romaneio.linhas_vigentes.do_veiculo(@veiculo).to_a if @veiculo
|
||||
@removidas = @romaneio.linhas_removidas.to_a
|
||||
@colunas_ausentes = colunas_ausentes_da_operacao
|
||||
# Mesma lista da tela de criação: o vínculo manual da operação acontece aqui
|
||||
# também, porque é aqui que se descobre que APARELHO saiu vazio.
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
# A prévia aberta é ESTADO NA URL (?previa=1), não estado de navegador. Ver o
|
||||
# comentário em _previa.html.erb: hash (#previa) não sobrevive ao Turbo e
|
||||
# checkbox não sobrevive à troca de folha.
|
||||
@previa = params[:previa].present?
|
||||
end
|
||||
|
||||
# Caminho A: busca o plano no SimpliRoute (API, com queda para o espelho).
|
||||
def create
|
||||
authorize Romaneio
|
||||
@romaneio = preparar_romaneio
|
||||
return if performed?
|
||||
|
||||
plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date)
|
||||
importar(plano.linhas, origem: plano.origem)
|
||||
rescue SimpliRoute::Error => e
|
||||
# NotFound herda de Error: a mensagem dele já diz para enviar o .xlsx.
|
||||
descartar_romaneio_vazio
|
||||
redirect_to admin_romaneios_path, alert: e.message
|
||||
end
|
||||
|
||||
# Caminho B: upload do .xlsx do plano, para quando o plano do dia ainda não foi
|
||||
# publicado ou a API não responde.
|
||||
def importar_planilha
|
||||
authorize Romaneio
|
||||
@romaneio = preparar_romaneio
|
||||
return if performed?
|
||||
|
||||
arquivo = params[:arquivo]
|
||||
if arquivo.blank?
|
||||
descartar_romaneio_vazio
|
||||
return redirect_to(admin_romaneios_path, alert: 'Selecione a planilha do plano (.xlsx).')
|
||||
end
|
||||
|
||||
importar(Romaneios::PlanoPlanilha.new(arquivo).linhas, origem: 'planilha')
|
||||
rescue Romaneios::PlanoPlanilha::Erro => e
|
||||
descartar_romaneio_vazio
|
||||
redirect_to admin_romaneios_path, alert: e.message
|
||||
end
|
||||
|
||||
# ── Logo do cabeçalho ──────────────────────────────────────────────────────
|
||||
# O romaneio é documento do CLIENTE, não da Reem: quando a operação muda de
|
||||
# contratante, trocar o logo não pode exigir deploy.
|
||||
#
|
||||
# PNG/JPG apenas porque é o que o Prawn embute — um SVG aceito aqui sairia como
|
||||
# folha SEM logo, sem erro nenhum, e ninguém descobriria antes de imprimir.
|
||||
LOGO_TIPOS = { 'image/png' => '.png', 'image/jpeg' => '.jpg' }.freeze
|
||||
LOGO_TAMANHO_MAX = 2.megabytes
|
||||
|
||||
def atualizar_logo
|
||||
authorize Romaneio
|
||||
|
||||
arquivo = params[:logo]
|
||||
return redirect_to(admin_romaneios_path, alert: 'Escolha o arquivo do logo (PNG ou JPG).') if arquivo.blank?
|
||||
|
||||
extensao = LOGO_TIPOS[arquivo.content_type.to_s]
|
||||
if extensao.nil?
|
||||
return redirect_to(admin_romaneios_path,
|
||||
alert: 'O logo precisa ser PNG ou JPG — é o que o PDF consegue embutir.')
|
||||
end
|
||||
if arquivo.size > LOGO_TAMANHO_MAX
|
||||
return redirect_to(admin_romaneios_path,
|
||||
alert: 'Logo acima de 2 MB. Reduza a imagem antes de enviar.')
|
||||
end
|
||||
|
||||
# Nome GERADO por nós: o nome que vem do navegador é entrada do usuário e não
|
||||
# entra em caminho de arquivo. O original fica só na descrição, para a tela
|
||||
# mostrar "Logo: LOGOGADE.png" como o operador reconhece.
|
||||
nome = "logo-#{Time.current.strftime('%Y%m%d%H%M%S')}#{extensao}"
|
||||
destino = Pdf::RomaneioPdf.pasta_logos
|
||||
FileUtils.mkdir_p(destino)
|
||||
File.binwrite(destino.join(nome), arquivo.read)
|
||||
|
||||
config = Configuracao.find_or_initialize_by(chave: 'romaneio_logo')
|
||||
config.update!(valor: nome, descricao: arquivo.original_filename.to_s.first(120),
|
||||
updated_by: current_user&.id)
|
||||
|
||||
redirect_to admin_romaneios_path,
|
||||
notice: "Logo atualizado: #{arquivo.original_filename}. Vale para os próximos PDFs."
|
||||
end
|
||||
|
||||
# Rebusca o plano e reconcilia com o que já está editado (a regra está em
|
||||
# Romaneios::Importador).
|
||||
def reimportar
|
||||
authorize @romaneio
|
||||
|
||||
# Reimportar com arquivo em mãos usa o arquivo; sem arquivo, volta ao
|
||||
# SimpliRoute — inclusive para um romaneio que nasceu de planilha (é justamente
|
||||
# assim que o operador troca o plano B pelo A quando a API volta).
|
||||
if params[:arquivo].present?
|
||||
plano = Romaneios::PlanoPlanilha.new(params[:arquivo])
|
||||
linhas = plano.linhas
|
||||
origem = 'planilha'
|
||||
else
|
||||
plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date)
|
||||
linhas = plano.linhas
|
||||
origem = plano.origem
|
||||
end
|
||||
|
||||
resultado = aplicar(linhas, origem: origem)
|
||||
auditar!(:reimportar, @romaneio, dados_novos: resultado.to_h)
|
||||
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
|
||||
notice: "Plano reimportado — #{resultado.resumo}."
|
||||
rescue SimpliRoute::Error, Romaneios::PlanoPlanilha::Erro => e
|
||||
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), alert: e.message
|
||||
end
|
||||
|
||||
# Vincula a operação do mês à mão e preenche APARELHO/TELEFONES/NOME a partir
|
||||
# dela. É a saída para quando o casamento pelo nome do plano não acontece — sem
|
||||
# isso o único caminho era reimportar, que depende da API responder ou de o
|
||||
# operador ainda ter o .xlsx do plano.
|
||||
def vincular_operacao
|
||||
authorize @romaneio
|
||||
|
||||
resultado = Romaneios::VinculadorDeOperacao.new(romaneio: @romaneio,
|
||||
tabela: params[:operacao_tabela]).aplicar!
|
||||
auditar!(:vincular_operacao, @romaneio,
|
||||
dados_novos: resultado.to_h.merge(operacao_tabela: @romaneio.operacao_tabela))
|
||||
|
||||
# Sem a coluna STATUS a operação não sabe quem é NOVO: o vínculo deu certo e
|
||||
# mesmo assim ninguém recebe aparelho. Dizer isso aqui evita a leitura de que
|
||||
# "vinculei e continua vazio, logo o sistema está errado".
|
||||
aviso = if resultado.colunas_ausentes.include?('status')
|
||||
" Atenção: esta operação não tem a coluna STATUS, então APARELHO continua vazio e precisa ser preenchido à mão."
|
||||
else
|
||||
''
|
||||
end
|
||||
|
||||
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
|
||||
notice: "Operação #{@romaneio.operacao_label} vinculada — #{resultado.resumo}.#{aviso}"
|
||||
rescue ArgumentError
|
||||
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
|
||||
alert: 'Selecione uma operação válida.'
|
||||
rescue Romaneios::VinculadorDeOperacao::Conflito => e
|
||||
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
|
||||
alert: "#{e.message} Abra o romaneio de " \
|
||||
"#{l e.existente.planned_date, format: '%d/%m/%Y'} (#{e.existente.rotulo_plano.presence || 'sem rótulo'}) " \
|
||||
'em vez de duplicar o documento do dia.'
|
||||
end
|
||||
|
||||
# Com ?veiculo= sai INLINE (é a prévia do editor, uma folha só, sub-100ms);
|
||||
# sem veiculo sai o romaneio inteiro como download.
|
||||
def pdf
|
||||
authorize @romaneio
|
||||
|
||||
veiculo = params[:veiculo].to_s
|
||||
binario = Pdf::RomaneioPdf.new(romaneio: @romaneio,
|
||||
veiculos: veiculo.presence && [veiculo]).render
|
||||
|
||||
# A prévia é regerada a cada edição: cache do navegador aqui devolveria o PDF
|
||||
# anterior e o operador acharia que a alteração não gravou.
|
||||
expires_now if veiculo.present?
|
||||
|
||||
send_data binario, type: 'application/pdf',
|
||||
disposition: veiculo.present? ? 'inline' : 'attachment',
|
||||
filename: nome_do_arquivo(veiculo)
|
||||
end
|
||||
|
||||
# JSON — grava UM campo de UMA parada. `valor: null` restaura o que o plano
|
||||
# trouxe (e destrava o campo para a próxima reimportação).
|
||||
def atualizar_linha
|
||||
authorize @romaneio
|
||||
|
||||
linha = @romaneio.romaneio_linhas.find(params[:linha_id])
|
||||
campo = params[:campo].to_s
|
||||
unless RomaneioLinha::CAMPOS_EDITAVEIS.include?(campo)
|
||||
return render json: { ok: false, erro: 'Campo não editável.' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:valor].nil?
|
||||
linha[campo] = linha.valor_do_plano(campo)
|
||||
linha.desmarcar_editado!(campo)
|
||||
else
|
||||
linha[campo] = params[:valor].to_s
|
||||
linha.marcar_editado!(campo)
|
||||
end
|
||||
linha.save!
|
||||
|
||||
# Devolvemos o valor GRAVADO e o JS o reescreve no input: a gravação é
|
||||
# last-write-wins, e assim uma sobreposição de duas abas fica visível em vez
|
||||
# de silenciosa.
|
||||
render json: { ok: true, valor: linha[campo], editado: linha.editado?(campo) }
|
||||
end
|
||||
|
||||
def atualizar_veiculo
|
||||
authorize @romaneio
|
||||
|
||||
registro = @romaneio.romaneio_veiculos.find_by!(veiculo: params[:veiculo].to_s)
|
||||
registro.update!(motorista: params[:motorista].to_s)
|
||||
render json: { ok: true, valor: registro.motorista }
|
||||
end
|
||||
|
||||
def atualizar_rotulo
|
||||
authorize @romaneio
|
||||
|
||||
@romaneio.update!(rotulo_plano: params[:rotulo_plano].to_s)
|
||||
render json: { ok: true, valor: @romaneio.rotulo_plano }
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @romaneio
|
||||
|
||||
# Auditar ANTES: depois do destroy não há mais atributos para registrar.
|
||||
auditar!(:excluir, @romaneio, dados_anteriores: @romaneio.attributes)
|
||||
@romaneio.destroy!
|
||||
redirect_to admin_romaneios_path, notice: 'Romaneio excluído.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def carregar_romaneio
|
||||
@romaneio = Romaneio.find(params[:id])
|
||||
end
|
||||
|
||||
# Quais colunas a operação NÃO tem. Perguntamos ao serviço em vez de deduzir de
|
||||
# "a coluna APARELHO saiu toda vazia": vazio também é o resultado legítimo de uma
|
||||
# operação só com pacientes recorrentes, e um aviso que erra o motivo é pior do
|
||||
# que nenhum.
|
||||
def colunas_ausentes_da_operacao
|
||||
return [] if @romaneio.operacao_tabela.blank?
|
||||
|
||||
Romaneios::EnriquecimentoGade.new(@romaneio.operacao_tabela).colunas_ausentes
|
||||
rescue ArgumentError
|
||||
# Operação removida do banco depois que o romaneio foi criado.
|
||||
Romaneios::EnriquecimentoGade::COLUNAS.keys
|
||||
end
|
||||
|
||||
# Acha (ou cria) o romaneio da data + operação. `find_or_create_by!` é o que faz
|
||||
# "importar de novo o mesmo dia" cair na reconciliação em vez de criar um segundo
|
||||
# romaneio — a chave única (planned_date, operacao_tabela) é a garantia disso.
|
||||
def preparar_romaneio
|
||||
data = params[:planned_date].presence
|
||||
if data.blank?
|
||||
redirect_to admin_romaneios_path, alert: 'Informe a data do plano.'
|
||||
return nil
|
||||
end
|
||||
|
||||
tabela = params[:operacao_tabela].to_s
|
||||
if tabela.present? && !Operacao.valida?(tabela)
|
||||
redirect_to admin_romaneios_path, alert: 'Selecione uma operação válida.'
|
||||
return nil
|
||||
end
|
||||
|
||||
# Sem operação escolhida, tenta deduzi-la do NOME DO PLANO ("ENTREGAS EMAD
|
||||
# 09.2026" → gade_entregas_emad_set_2026).
|
||||
#
|
||||
# POR QUE ISSO É IMPORTANTE E NÃO CONVENIÊNCIA: é a operação que diz quem é
|
||||
# paciente NOVO. Importar sem ela produz um romaneio de aparência normal com a
|
||||
# coluna APARELHO vazia para TODOS — o erro só aparece no papel, com aparelho
|
||||
# não entregue. A operação não é campo opcional na prática; era só difícil de
|
||||
# acertar, porque o nome do plano e o nome da tabela nunca são iguais.
|
||||
#
|
||||
# Empate/nenhum casamento devolve nil e segue sem operação: vincular a errada
|
||||
# preencheria APARELHO e TELEFONE com dados de OUTRO mês, sem ninguém
|
||||
# desconfiar. A tela avisa quando ficou sem.
|
||||
@operacao_deduzida = nil
|
||||
if tabela.blank? && params[:rotulo_plano].present?
|
||||
tabela = Romaneios::CasadorDeOperacao.casar(params[:rotulo_plano]).to_s
|
||||
@operacao_deduzida = tabela.presence
|
||||
end
|
||||
|
||||
data = Date.parse(data)
|
||||
Romaneio.find_or_create_by!(planned_date: data, operacao_tabela: tabela) do |r|
|
||||
r.rotulo_plano = params[:rotulo_plano].presence || Romaneio.rotulo_sugerido(tabela, data)
|
||||
r.importado_por = current_user
|
||||
end
|
||||
rescue Date::Error
|
||||
redirect_to admin_romaneios_path, alert: 'Data do plano inválida.'
|
||||
nil
|
||||
end
|
||||
|
||||
def importar(linhas, origem:)
|
||||
resultado = aplicar(linhas, origem: origem)
|
||||
auditar!(:criar, @romaneio, dados_novos: resultado.to_h)
|
||||
|
||||
# Dizer que a operação foi deduzida, e qual: dedução silenciosa que acerta é
|
||||
# invisível, mas dedução silenciosa que erra é indefensável — o operador
|
||||
# precisa ver de qual mês vieram os dados de APARELHO.
|
||||
aviso = if @operacao_deduzida
|
||||
" Operação #{Operacao.label(@operacao_deduzida)} vinculada pelo nome do plano."
|
||||
else
|
||||
''
|
||||
end
|
||||
|
||||
redirect_to admin_romaneio_path(@romaneio),
|
||||
notice: "Plano importado — #{resultado.resumo}.#{aviso}"
|
||||
end
|
||||
|
||||
def aplicar(linhas, origem:)
|
||||
Romaneios::Importador.new(romaneio: @romaneio, linhas: linhas,
|
||||
usuario: current_user, origem: origem).aplicar!
|
||||
end
|
||||
|
||||
# Um romaneio recém-criado que não chegou a receber nenhuma parada é lixo: some
|
||||
# da lista para o operador não achar que a importação funcionou.
|
||||
def descartar_romaneio_vazio
|
||||
@romaneio&.destroy! if @romaneio&.persisted? && @romaneio.romaneio_linhas.none?
|
||||
end
|
||||
|
||||
def veiculo_selecionado
|
||||
pedido = params[:veiculo].to_s
|
||||
return pedido if @veiculos.any? { |v| v.veiculo == pedido }
|
||||
|
||||
@veiculos.first&.veiculo
|
||||
end
|
||||
|
||||
def nome_do_arquivo(veiculo)
|
||||
partes = ['romaneio', @romaneio.rotulo_plano.presence || @romaneio.planned_date.to_s, veiculo]
|
||||
"#{partes.compact_blank.join('_').parameterize(separator: '_')}.pdf"
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ class Admin::UsuariosController < ApplicationController
|
||||
|
||||
def index
|
||||
authorize User
|
||||
@usuarios = policy_scope(User).includes(:perfil_acesso).order(:nome).limit(200)
|
||||
@usuarios = policy_scope(User).order(:nome).limit(200)
|
||||
@roles = User.roles.keys
|
||||
end
|
||||
|
||||
@@ -36,13 +36,6 @@ class Admin::UsuariosController < ApplicationController
|
||||
def update
|
||||
authorize @usuario
|
||||
|
||||
# Trava do último administrador: mudar perfil ou desativar a única pessoa que
|
||||
# ainda administra acesso deixaria o sistema sem quem conserte.
|
||||
if desligaria_o_ultimo_administrador?
|
||||
@usuario.errors.add(:base, MENSAGEM_ULTIMO_ADMIN)
|
||||
return render(:edit, status: :unprocessable_entity)
|
||||
end
|
||||
|
||||
if @usuario.update(usuario_params_update)
|
||||
AuditoriaLog.registrar(user: current_user, acao: 'editar_usuario', entidade: 'User', dados_novos: { detalhes: "Editou usuário #{@usuario.email}" }, request: request)
|
||||
redirect_to admin_usuarios_path, notice: 'Usuário atualizado.'
|
||||
@@ -54,10 +47,6 @@ class Admin::UsuariosController < ApplicationController
|
||||
def destroy
|
||||
authorize @usuario
|
||||
|
||||
if User.ultimo_administrador?(@usuario)
|
||||
return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN)
|
||||
end
|
||||
|
||||
if @usuario == current_user
|
||||
redirect_to admin_usuarios_path, alert: 'Você não pode excluir sua própria conta.'
|
||||
return
|
||||
@@ -69,11 +58,7 @@ class Admin::UsuariosController < ApplicationController
|
||||
end
|
||||
|
||||
def toggle_ativo
|
||||
authorize @usuario, :toggle_ativo?
|
||||
|
||||
if @usuario.ativo? && User.ultimo_administrador?(@usuario)
|
||||
return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN)
|
||||
end
|
||||
authorize @usuario, :update?
|
||||
@usuario.update!(ativo: !@usuario.ativo)
|
||||
status = @usuario.ativo? ? 'ativado' : 'desativado'
|
||||
AuditoriaLog.registrar(user: current_user, acao: "usuario_#{status}", entidade: 'User', dados_novos: { detalhes: "#{@usuario.email} foi #{status}" }, request: request)
|
||||
@@ -82,53 +67,18 @@ class Admin::UsuariosController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
MENSAGEM_ULTIMO_ADMIN = 'Esta alteração deixaria o sistema sem nenhum usuário ativo capaz de ' \
|
||||
'gerenciar perfis e usuários. Dê esse acesso a outra pessoa antes.'.freeze
|
||||
|
||||
def set_usuario
|
||||
@usuario = User.find(params[:id])
|
||||
end
|
||||
|
||||
# Aplica as mudanças em memória e pergunta: sobra alguém administrando?
|
||||
def desligaria_o_ultimo_administrador?
|
||||
simulado = @usuario.dup
|
||||
simulado.id = @usuario.id
|
||||
simulado.assign_attributes(usuario_params_update)
|
||||
simulado.recarregar_permissoes!
|
||||
|
||||
return false if simulado.ativo? && simulado.pode?('admin.perfis') && simulado.pode?('admin.usuarios_gerenciar')
|
||||
|
||||
User.ultimo_administrador?(@usuario)
|
||||
end
|
||||
|
||||
# ⚠️ CAMPOS DE ACESSO SÓ PARA QUEM PODE MEXER NELES.
|
||||
# `UserPolicy#update?` libera editar a PRÓPRIA ficha (nome, senha) — é o que
|
||||
# permite alguém trocar a própria senha. Sem o filtro abaixo, essa mesma
|
||||
# permissão deixava qualquer usuário logado enviar `user[role]=admin` na edição
|
||||
# do próprio cadastro e se promover. Quem decide papel e perfil é
|
||||
# `UserPolicy#alterar_acesso?` (nunca sobre si mesmo).
|
||||
CAMPOS_DE_ACESSO = %i[role perfil_acesso_id ativo].freeze
|
||||
|
||||
def usuario_params
|
||||
permitidos = [:nome, :email, :password, :password_confirmation, :pin_code]
|
||||
permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso?
|
||||
params.require(:user).permit(permitidos)
|
||||
params.require(:user).permit(:nome, :email, :password, :password_confirmation,
|
||||
:role, :pin_code, :ativo)
|
||||
end
|
||||
|
||||
def usuario_params_update
|
||||
permitidos = [:nome, :email, :pin_code]
|
||||
permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso?
|
||||
permitidos += [:password, :password_confirmation] if params[:user][:password].present?
|
||||
params.require(:user).permit(permitidos)
|
||||
end
|
||||
|
||||
def pode_alterar_acesso?
|
||||
if @usuario&.persisted?
|
||||
policy(@usuario).alterar_acesso?
|
||||
else
|
||||
# Criação: não há "si mesmo" para se promover, basta poder gerenciar.
|
||||
current_user.pode?('admin.usuarios_gerenciar')
|
||||
permitted = [:nome, :email, :role, :pin_code, :ativo]
|
||||
permitted += [:password, :password_confirmation] if params[:user][:password].present?
|
||||
params.require(:user).permit(permitted)
|
||||
end
|
||||
end
|
||||
helper_method :pode_alterar_acesso?
|
||||
end
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# app/controllers/admin/variaveis_personalizadas_controller.rb
|
||||
#
|
||||
# CRUD das {{variaveis}} que o ADM cria. Uma vez cadastradas, aparecem no editor
|
||||
# de mensagem ao lado das variáveis do sistema e valem para qualquer evento.
|
||||
module Admin
|
||||
class VariaveisPersonalizadasController < ApplicationController
|
||||
include Auditavel
|
||||
|
||||
before_action :set_variavel, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
authorize VariavelPersonalizada
|
||||
@variaveis = policy_scope(VariavelPersonalizada).ordenadas
|
||||
# Mostrar o catálogo do sistema junto evita o erro mais comum: cadastrar
|
||||
# {{data}} sem saber que já existe (o model recusa, mas melhor avisar antes).
|
||||
@do_sistema = Notificacao::CatalogoVariaveis.por_origem.except(:personalizada)
|
||||
end
|
||||
|
||||
def new
|
||||
@variavel = VariavelPersonalizada.new(ativo: true)
|
||||
authorize @variavel
|
||||
end
|
||||
|
||||
def create
|
||||
@variavel = VariavelPersonalizada.new(variavel_params)
|
||||
authorize @variavel
|
||||
|
||||
if @variavel.save
|
||||
auditar!(:criar, @variavel, dados_novos: { chave: @variavel.chave, valor: @variavel.valor })
|
||||
redirect_to admin_variaveis_path, notice: "Variável #{@variavel.marcador} criada."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def update
|
||||
anterior = @variavel.valor
|
||||
|
||||
if @variavel.update(variavel_params)
|
||||
auditar!(:editar, @variavel,
|
||||
dados_anteriores: { valor: anterior },
|
||||
dados_novos: { valor: @variavel.valor })
|
||||
redirect_to admin_variaveis_path, notice: "Variável #{@variavel.marcador} atualizada."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
marcador = @variavel.marcador
|
||||
@variavel.destroy
|
||||
auditar!(:excluir, @variavel, dados_anteriores: { chave: @variavel.chave })
|
||||
|
||||
# A variável some, mas o texto que a usava continua lá: sem valor, o
|
||||
# renderizador troca por vazio. Avisar é melhor do que a mensagem sair com
|
||||
# um buraco que ninguém relaciona a esta exclusão.
|
||||
redirect_to admin_variaveis_path,
|
||||
alert: "Variável #{marcador} excluída. Mensagens que a usavam passam a mostrar espaço em branco no lugar."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_variavel
|
||||
@variavel = VariavelPersonalizada.find(params[:id])
|
||||
authorize @variavel
|
||||
end
|
||||
|
||||
def variavel_params
|
||||
params.require(:variavel_personalizada).permit(:chave, :valor, :descricao, :ativo)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,80 +0,0 @@
|
||||
# app/controllers/admin/whatsapp_sessoes_controller.rb
|
||||
#
|
||||
# Pareamento do WhatsApp por QR code (ponte Baileys, container `whatsapp`).
|
||||
#
|
||||
# ⚠️ Canal NÃO OFICIAL: parear liga o número da empresa a este servidor por uma
|
||||
# sessão do WhatsApp Web obtida por engenharia reversa. Está fora dos Termos
|
||||
# do WhatsApp e a Meta pode banir o número — use um chip dedicado. A tela
|
||||
# avisa isso em texto, não só no código.
|
||||
class Admin::WhatsappSessoesController < ApplicationController
|
||||
def show
|
||||
authorize :whatsapp_sessao, :show?
|
||||
@config = ConfiguracaoNotificacao.instancia
|
||||
@cliente = Notificacao::ClienteWhatsapp.padrao
|
||||
@estado = consultar
|
||||
end
|
||||
|
||||
# Polling da tela enquanto o QR não é lido — o QR expira em ~20s e a ponte
|
||||
# gera outro, então a página precisa buscar o novo sozinha.
|
||||
def status
|
||||
authorize :whatsapp_sessao, :show?
|
||||
render json: consultar
|
||||
end
|
||||
|
||||
def testar
|
||||
authorize :whatsapp_sessao, :testar?
|
||||
|
||||
destino = ConfiguracaoNotificacao.normalizar_telefone(params[:numero])
|
||||
if destino.blank?
|
||||
return redirect_to(admin_whatsapp_sessao_path, alert: 'Informe um número válido (DDD + número).')
|
||||
end
|
||||
|
||||
resposta = Notificacao::ClienteWhatsapp.padrao.enviar(
|
||||
para: destino,
|
||||
texto: "Teste do Reem Notas ✅\nSe você recebeu isto, o WhatsApp está conectado."
|
||||
)
|
||||
|
||||
if resposta.ok?
|
||||
redirect_to admin_whatsapp_sessao_path, notice: "Mensagem de teste enviada para #{destino}."
|
||||
else
|
||||
redirect_to admin_whatsapp_sessao_path, alert: "Falhou: #{resposta.erro}"
|
||||
end
|
||||
end
|
||||
|
||||
def desconectar
|
||||
authorize :whatsapp_sessao, :desconectar?
|
||||
|
||||
resposta = Notificacao::ClienteWhatsapp.padrao.desconectar
|
||||
AuditoriaLog.registrar(user: current_user, acao: 'whatsapp_desconectar',
|
||||
entidade: 'ConfiguracaoNotificacao',
|
||||
dados_novos: { detalhes: 'Desconectou a sessão do WhatsApp' },
|
||||
request: request)
|
||||
|
||||
if resposta.ok?
|
||||
ConfiguracaoNotificacao.instancia.update_columns(whatsapp_numero_conectado: nil,
|
||||
whatsapp_conectado_em: nil)
|
||||
redirect_to admin_whatsapp_sessao_path, notice: 'Sessão encerrada. Leia o QR para conectar de novo.'
|
||||
else
|
||||
redirect_to admin_whatsapp_sessao_path, alert: "Não foi possível desconectar: #{resposta.erro}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Consulta a ponte e memoriza o número conectado no banco — assim a tela de
|
||||
# configuração mostra quem está pareado mesmo com a ponte fora do ar.
|
||||
def consultar
|
||||
resposta = Notificacao::ClienteWhatsapp.padrao.status
|
||||
return { 'conectado' => false, 'erro' => resposta.erro } unless resposta.ok?
|
||||
|
||||
dados = resposta.dados || {}
|
||||
if dados['conectado'] && dados['numero'].present?
|
||||
config = ConfiguracaoNotificacao.instancia
|
||||
if config.whatsapp_numero_conectado != dados['numero']
|
||||
config.update_columns(whatsapp_numero_conectado: dados['numero'],
|
||||
whatsapp_conectado_em: Time.current)
|
||||
end
|
||||
end
|
||||
dados
|
||||
end
|
||||
end
|
||||
@@ -7,31 +7,14 @@ class ApplicationController < ActionController::Base
|
||||
before_action :set_tema
|
||||
before_action :preparar_splash
|
||||
|
||||
# Pundit: redireciona se não autorizado.
|
||||
#
|
||||
# ⚠️ NÃO redirecione para `root_path` aqui: a raiz é o dashboard, e um perfil
|
||||
# sem `dashboard.ver` entraria em loop (nega → raiz → nega). O destino é a
|
||||
# primeira tela que a PESSOA pode abrir; quem não pode abrir nenhuma vai para a
|
||||
# tela de sem-acesso, que explica a quem pedir.
|
||||
rescue_from Pundit::NotAuthorizedError do |_e|
|
||||
flash[:alert] = 'Você não tem permissão para acessar essa área.'
|
||||
redirect_to destino_permitido
|
||||
# Pundit: redireciona se não autorizado
|
||||
rescue_from Pundit::NotAuthorizedError do |e|
|
||||
flash[:alert] = "Você não tem permissão para realizar esta ação."
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Rota inicial deste usuário (ver User#home_rota). `sem_acesso_path` é o fim de
|
||||
# linha: perfil sem nenhuma permissão.
|
||||
def destino_permitido
|
||||
rota = current_user&.home_rota
|
||||
return sem_acesso_path if rota.nil?
|
||||
|
||||
# Evita loop quando a própria tela negada é a home calculada.
|
||||
destino = public_send(rota)
|
||||
destino == request.path ? sem_acesso_path : destino
|
||||
end
|
||||
helper_method :destino_permitido
|
||||
|
||||
def set_tema
|
||||
@tema = current_user&.tema_preferido || 'dark'
|
||||
end
|
||||
@@ -45,11 +28,8 @@ class ApplicationController < ActionController::Base
|
||||
@mostrar_splash = session.delete(:mostrar_splash)
|
||||
end
|
||||
|
||||
# Mesma regra do Users::SessionsController: o destino é a primeira tela que o
|
||||
# PERFIL da pessoa permite, não uma rota fixa por papel.
|
||||
def after_sign_in_path_for(resource)
|
||||
session[:mostrar_splash] = true
|
||||
rota = resource.home_rota
|
||||
rota ? public_send(rota) : sem_acesso_path
|
||||
resource.motorista? ? motorista_dashboard_path : dashboard_path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -433,7 +433,7 @@ class ConsolidacaoEntregasController < ApplicationController
|
||||
end
|
||||
|
||||
def bloquear_se_finalizada
|
||||
return unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')
|
||||
return unless @consolidacao.finalizada? && current_user.operador?
|
||||
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
|
||||
end
|
||||
|
||||
|
||||
@@ -13,37 +13,17 @@ class ConsolidacoesController < ApplicationController
|
||||
# Formas de pagamento aceitas (o select da UI usa esta mesma lista).
|
||||
FORMAS_PAGAMENTO = %w[pix transferencia dinheiro].freeze
|
||||
|
||||
# GET /consolidacoes?visao=lista|motoristas — a MESMA tela em dois modos de
|
||||
# visualização, com os mesmos filtros:
|
||||
#
|
||||
# lista → um card por CONSOLIDAÇÃO (o de sempre);
|
||||
# motoristas → um card por MOTORISTA, com o total dele no período e, ao
|
||||
# clicar, as consolidações que compõem aquele valor.
|
||||
#
|
||||
# São duas leituras do mesmo recorte: o trabalho é organizado por consolidação,
|
||||
# mas o pagamento é por motorista — antes, para saber quanto um motorista tinha
|
||||
# fechado no mês, era preciso abrir consolidação por consolidação e somar à mão.
|
||||
# GET /consolidacoes — lista com filtros
|
||||
def index
|
||||
authorize Consolidacao
|
||||
|
||||
@visao = params[:visao] == 'motoristas' ? 'motoristas' : 'lista'
|
||||
|
||||
@consolidacoes = Consolidacao.ativas.recentes.includes(:criador)
|
||||
|
||||
# Filtros
|
||||
@consolidacoes = @consolidacoes.where(status: params[:status]) if params[:status].present?
|
||||
@consolidacoes = @consolidacoes.where('nome ILIKE ?', "%#{params[:nome]}%") if params[:nome].present?
|
||||
# Datas: a consolidação entra se CRUZA a faixa — mesmo critério do modo "Por
|
||||
# motorista" e do dashboard financeiro. Com o critério antigo (caber inteira
|
||||
# dentro do filtro), um fechamento de 01/08 a 31/08 sumia da lista ao filtrar
|
||||
# 01/08–26/08 e as duas abas da MESMA tela contavam coisas diferentes.
|
||||
if params[:inicio].present? && params[:fim].present?
|
||||
@consolidacoes = @consolidacoes.cruzando_periodo(params[:inicio], params[:fim])
|
||||
elsif params[:inicio].present?
|
||||
@consolidacoes = @consolidacoes.where('data_fim >= ?', params[:inicio])
|
||||
elsif params[:fim].present?
|
||||
@consolidacoes = @consolidacoes.where('data_inicio <= ?', params[:fim])
|
||||
end
|
||||
@consolidacoes = @consolidacoes.where('data_inicio >= ?', params[:inicio]) if params[:inicio].present?
|
||||
@consolidacoes = @consolidacoes.where('data_fim <= ?', params[:fim]) if params[:fim].present?
|
||||
|
||||
if params[:motorista].present?
|
||||
ids = ConsolidacaoMotorista.where('motorista_nome ILIKE ?', "%#{params[:motorista]}%")
|
||||
@@ -58,30 +38,6 @@ class ConsolidacoesController < ApplicationController
|
||||
if %w[pago pendente parcial].include?(params[:pagamento])
|
||||
@consolidacoes = @consolidacoes.public_send("pagamento_#{params[:pagamento]}")
|
||||
end
|
||||
|
||||
montar_totais if @visao == 'motoristas'
|
||||
end
|
||||
|
||||
# GET /consolidacoes/totais_pdf — o modo "Por motorista" em PDF, com os mesmos
|
||||
# filtros da tela. Com ?detalhe=NOME sai só o extrato daquele motorista (com
|
||||
# espaço de assinatura).
|
||||
def totais_pdf
|
||||
authorize Consolidacao, :exportar_pdf?
|
||||
|
||||
inicio, fim = periodo_totais
|
||||
totais = Analytics::TotaisPorMotorista.new(
|
||||
inicio: inicio, fim: fim,
|
||||
motorista: params[:detalhe].presence,
|
||||
status: params[:status].presence || 'finalizada',
|
||||
busca: params[:motorista].presence
|
||||
)
|
||||
|
||||
nome = params[:detalhe].presence&.parameterize(separator: '_')
|
||||
arquivo = ['totais', nome, inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].compact.join('_')
|
||||
send_data Pdf::TotaisMotoristaPdf.new(totais: totais, detalhar: params[:detalhar] != '0').render,
|
||||
filename: "#{arquivo}.pdf",
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
# GET /consolidacoes/new
|
||||
@@ -245,7 +201,7 @@ class ConsolidacoesController < ApplicationController
|
||||
|
||||
# POST /consolidacoes/:id/finalizar
|
||||
def finalizar
|
||||
authorize @consolidacao, :finalizar?
|
||||
authorize @consolidacao, :update?
|
||||
|
||||
if @consolidacao.finalizar!(current_user)
|
||||
auditar!(:finalizar, @consolidacao)
|
||||
@@ -262,7 +218,7 @@ class ConsolidacoesController < ApplicationController
|
||||
# não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os
|
||||
# veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora.
|
||||
def finalizar_parcial
|
||||
authorize @consolidacao, :finalizar_parcial?
|
||||
authorize @consolidacao, :update?
|
||||
|
||||
arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user)
|
||||
if @consolidacao.finalizar!(current_user)
|
||||
@@ -278,7 +234,7 @@ class ConsolidacoesController < ApplicationController
|
||||
|
||||
# POST /consolidacoes/:id/arquivar
|
||||
def arquivar
|
||||
authorize @consolidacao, :arquivar?
|
||||
authorize @consolidacao, :destroy?
|
||||
@consolidacao.arquivar!(current_user)
|
||||
auditar!(:arquivar, @consolidacao)
|
||||
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
|
||||
@@ -292,7 +248,7 @@ class ConsolidacoesController < ApplicationController
|
||||
|
||||
# POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada
|
||||
def reativar
|
||||
authorize @consolidacao, :reativar?
|
||||
authorize @consolidacao, :destroy?
|
||||
@consolidacao.reativar!(current_user)
|
||||
auditar!(:reativar, @consolidacao)
|
||||
redirect_to consolidacoes_path, notice: 'Consolidação reativada.'
|
||||
@@ -350,7 +306,7 @@ class ConsolidacoesController < ApplicationController
|
||||
# consolidação (reversível). Ele sai dos totais/pagamento/gate, mas os dados
|
||||
# ficam guardados e podem ser restaurados. Não arquiva motorista já pago.
|
||||
def arquivar_motorista
|
||||
authorize @consolidacao, :gerir_motoristas?
|
||||
authorize @consolidacao, :update?
|
||||
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
||||
|
||||
if cm.pago?
|
||||
@@ -367,7 +323,7 @@ class ConsolidacoesController < ApplicationController
|
||||
|
||||
# POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado.
|
||||
def desarquivar_motorista
|
||||
authorize @consolidacao, :gerir_motoristas?
|
||||
authorize @consolidacao, :update?
|
||||
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
||||
|
||||
cm.desarquivar!(current_user)
|
||||
@@ -382,7 +338,7 @@ class ConsolidacoesController < ApplicationController
|
||||
# como consolidacao_entregas se liga ao motorista por STRING (motorista_nome,
|
||||
# sem FK/cascade), apagamos os lançamentos dele à mão antes de remover o registro.
|
||||
def excluir_motorista
|
||||
authorize @consolidacao, :excluir_motorista?
|
||||
authorize @consolidacao, :destroy?
|
||||
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
||||
|
||||
if cm.pago?
|
||||
@@ -407,7 +363,7 @@ class ConsolidacoesController < ApplicationController
|
||||
# consolidação (ou restaura, se estava arquivado). Útil para refazer rápido
|
||||
# depois de excluir/arquivar por erro de precificação.
|
||||
def adicionar_motorista
|
||||
authorize @consolidacao, :gerir_motoristas?
|
||||
authorize @consolidacao, :update?
|
||||
nome = params[:motorista_nome].to_s.strip
|
||||
|
||||
if nome.blank?
|
||||
@@ -435,7 +391,7 @@ class ConsolidacoesController < ApplicationController
|
||||
|
||||
# GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X
|
||||
def gerar_pdf_relatorio
|
||||
authorize @consolidacao, :exportar_pdf?
|
||||
authorize @consolidacao, :show?
|
||||
motorista = params[:motorista]
|
||||
|
||||
pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista)
|
||||
@@ -449,7 +405,7 @@ class ConsolidacoesController < ApplicationController
|
||||
|
||||
# GET /consolidacoes/:id/gerar_pdf_financeiro — relatório financeiro da consolidação
|
||||
def gerar_pdf_financeiro
|
||||
authorize @consolidacao, :exportar_pdf?
|
||||
authorize @consolidacao, :show?
|
||||
|
||||
pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao)
|
||||
auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' })
|
||||
@@ -493,40 +449,6 @@ class ConsolidacoesController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
# Modo "Por motorista": mesmo período/busca da lista, mas o recorte de datas e
|
||||
# o default de status são os do DASHBOARD FINANCEIRO — é com ele que o operador
|
||||
# compara o número (ver Analytics::TotaisPorMotorista). Sem status na URL vale
|
||||
# "Consolidado" (só finalizadas): "Geral" (com rascunhos) mostrava um valor
|
||||
# maior que o do dashboard e parecia erro de conta.
|
||||
# `detalhe` é o motorista aberto (o clique na linha).
|
||||
def montar_totais
|
||||
@inicio, @fim = periodo_totais
|
||||
@status_totais = params[:status].presence || 'finalizada'
|
||||
@totais = Analytics::TotaisPorMotorista.new(
|
||||
inicio: @inicio, fim: @fim,
|
||||
status: @status_totais,
|
||||
busca: params[:motorista].presence
|
||||
)
|
||||
@detalhe_motorista = params[:detalhe].presence
|
||||
@detalhe = @detalhe_motorista ? @totais.consolidacoes_de(@detalhe_motorista) : nil
|
||||
end
|
||||
|
||||
# Período do relatório de totais. Default: o MÊS CORRENTE INTEIRO (o mês é a
|
||||
# unidade de fechamento; "até hoje" recortaria o mês pela metade sem motivo).
|
||||
def periodo_totais
|
||||
inicio = parse_data_totais(params[:inicio]) || Date.current.beginning_of_month
|
||||
fim = parse_data_totais(params[:fim]) || Date.current.end_of_month
|
||||
inicio, fim = fim, inicio if fim < inicio
|
||||
[inicio, fim]
|
||||
end
|
||||
|
||||
def parse_data_totais(str)
|
||||
return nil if str.blank?
|
||||
Date.parse(str)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
# Acha o User motorista pelo nome para gerar o QR Code de acesso. O
|
||||
# motorista_nome vem dos dados externos de entrega e o users.nome é digitado
|
||||
# no admin — então toleramos diferenças de caixa e de espaços (extras/duplos).
|
||||
|
||||
@@ -3,16 +3,11 @@ class DashboardController < ApplicationController
|
||||
MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre)
|
||||
|
||||
def index
|
||||
skip_authorization
|
||||
|
||||
# Motorista tem painel próprio — não enxerga o dashboard administrativo
|
||||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||||
|
||||
authorize :dashboard, :ver?
|
||||
|
||||
# Custo, pagamento e ticket médio são permissão à parte: um perfil de
|
||||
# acompanhamento vê a operação sem ver quanto ela custa. Antes o controller
|
||||
# usava skip_authorization e o papel `externo` enxergava tudo.
|
||||
@ver_financeiro = current_user.pode?('dashboard.financeiro')
|
||||
|
||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||
carregar_dados_dashboard
|
||||
end
|
||||
@@ -20,11 +15,9 @@ class DashboardController < ApplicationController
|
||||
# GET /dashboard/relatorio_financeiro?inicio=&fim=&operacoes[]=
|
||||
# Relatório financeiro do período (PDF) — mesmos números da tela.
|
||||
def relatorio_financeiro
|
||||
skip_authorization
|
||||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||||
|
||||
authorize :dashboard, :financeiro?
|
||||
@ver_financeiro = true
|
||||
|
||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||
carregar_dados_dashboard
|
||||
|
||||
@@ -57,41 +50,6 @@ class DashboardController < ApplicationController
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
# GET /dashboard/pendentes?inicio=&fim=&operacoes[]=
|
||||
# Quais são as entregas EM ABERTO do período — o card do dashboard mostrava só
|
||||
# o número e não havia tela nenhuma listando as linhas por trás dele.
|
||||
#
|
||||
# Em aberto = nem concluída nem falhada (Entrega.pendentes), pela data
|
||||
# PLANEJADA: é o único recorte sem checkout, então planned_date é o eixo
|
||||
# possível. Mesmo recorte do card, para o total bater.
|
||||
def pendentes
|
||||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||||
|
||||
authorize :dashboard, :pendentes?
|
||||
|
||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
|
||||
|
||||
escopo = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim)
|
||||
escopo = escopo.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||||
|
||||
@total_em_aberto = escopo.count
|
||||
begin
|
||||
@pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50)
|
||||
rescue Pagy::OverflowError
|
||||
@pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50, page: 1)
|
||||
end
|
||||
|
||||
# Contexto de cada NF listada: em que operação ela está (se estiver) e
|
||||
# quantas visitas o rastreio tem para ela. As duas respostas juntas dizem por
|
||||
# que a entrega ficou em aberto — e por que o dashboard de Operações não a
|
||||
# mostra: ou é nota fora da operação, ou tem outra visita que a substituiu.
|
||||
nfs = @em_aberto.map { |e| e.reference_id.to_s }.reject(&:empty?)
|
||||
@operacao_por_nf = Operacao.por_notas(nfs)
|
||||
@visitas_por_nf = nfs.any? ? Entrega.where(reference_id: nfs).group(:reference_id).count : {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Faixa de datas vinda do filtro de calendário (params inicio/fim).
|
||||
@@ -144,13 +102,6 @@ class DashboardController < ApplicationController
|
||||
@entregas_pendentes = pendentes.count
|
||||
@total_entregas = @entregas_atendidas + @entregas_pendentes
|
||||
|
||||
# Quantas NOTAS FISCAIS distintas estão por trás das visitas atendidas. É o
|
||||
# número que o dashboard de Operações mostra (e que o cliente paga/confere
|
||||
# nos documentos); aqui contamos VISITAS, porque é por ida ao local que o
|
||||
# motorista recebe. Exibir os dois lado a lado evita a leitura de que uma das
|
||||
# telas está errada quando elas divergem — a diferença são retentativas.
|
||||
@notas_atendidas = atendidas.distinct.count(:reference_id)
|
||||
|
||||
# Configurações de preço
|
||||
config = Configuracao.mapa_de_precos
|
||||
|
||||
@@ -187,13 +138,12 @@ class DashboardController < ApplicationController
|
||||
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
|
||||
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
|
||||
|
||||
# Pagamentos e visão financeira só são consultados por quem pode vê-los: sem a
|
||||
# permissão, o dado não chega à página (a view esconde os blocos, mas o certo
|
||||
# é nem carregar — é consulta cara e é informação de custo).
|
||||
if @ver_financeiro
|
||||
# Pagamentos: pago vs pendente entre as consolidações FINALIZADAS do período
|
||||
# (medido por motorista — fonte: consolidacao_motoristas).
|
||||
carregar_dados_pagamentos
|
||||
|
||||
# Visão financeira por operação (KPIs + gráficos)
|
||||
carregar_dados_financeiro
|
||||
end
|
||||
|
||||
# Histórico estimado mais recente
|
||||
@historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5)
|
||||
|
||||
@@ -3,54 +3,11 @@ module Motorista
|
||||
class DashboardController < ApplicationController
|
||||
before_action :garantir_motorista
|
||||
|
||||
# GET /motorista?inicio=&fim=
|
||||
#
|
||||
# UMA tela só: o motorista escolhe o período e vê, para aquele recorte, o que
|
||||
# já foi FECHADO pela empresa, o que ainda é ESTIMATIVA e de onde vem cada
|
||||
# centavo (os fechamentos que compõem o total). Antes o total ficava numa
|
||||
# tela e a composição em outra — e nenhuma das duas tinha período.
|
||||
# GET /motorista
|
||||
def index
|
||||
nome = current_user.nome
|
||||
|
||||
@inicio, @fim = periodo_selecionado
|
||||
@faixa_total = faixa_completa(nome) # para o atalho "Tudo"
|
||||
|
||||
carregar_estimado(nome)
|
||||
carregar_fechado(nome)
|
||||
|
||||
# Aviso in-app: pagamentos confirmados nos últimos 7 dias (notificação na
|
||||
# própria página do motorista, além de WhatsApp/email). Independe do
|
||||
# período escolhido — é um aviso, não um relatório.
|
||||
@pagamentos_recentes = ConsolidacaoMotorista
|
||||
.joins(:consolidacao)
|
||||
.where(motorista_nome: nome,
|
||||
consolidacoes: { status: :finalizada, deleted_at: nil })
|
||||
.where.not(pago_em: nil)
|
||||
.where('pago_em >= ?', 7.days.ago)
|
||||
.order(pago_em: :desc)
|
||||
.includes(:consolidacao)
|
||||
end
|
||||
|
||||
# GET /motorista/totais/pdf?inicio=&fim= — o extrato do período (total,
|
||||
# composição e assinaturas), para guardar ou mandar por WhatsApp. Mesmo
|
||||
# service da tela, então o papel e o painel nunca divergem.
|
||||
def totais_pdf
|
||||
inicio, fim = periodo_selecionado
|
||||
totais = Analytics::TotaisPorMotorista.new(
|
||||
inicio: inicio, fim: fim, motorista: current_user.nome
|
||||
)
|
||||
|
||||
arquivo = ['meu_total', current_user.nome.parameterize(separator: '_'),
|
||||
inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].join('_')
|
||||
send_data Pdf::TotaisMotoristaPdf.new(totais: totais).render,
|
||||
filename: "#{arquivo}.pdf",
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Card ESTIMADO: entregas ATENDIDAS × preco_entrega dentro do período.
|
||||
# Card 1 — Valor ESTIMADO (mês atual): entregas ATENDIDAS × preco_entrega.
|
||||
#
|
||||
# Atendida = o motorista FOI ao local e fechou a visita, com sucesso
|
||||
# ('completed') OU insucesso ('failed') — no insucesso o deslocamento
|
||||
@@ -62,19 +19,8 @@ module Motorista
|
||||
# ⚠️ O eixo é o CHECKOUT (data real da visita), não planned_date: falhada TEM
|
||||
# checkout, e uma entrega planejada em 31/07 mas fechada em 01/08 pertence a
|
||||
# agosto — igual ao que a consolidação considera elegível.
|
||||
#
|
||||
# O fim é limitado a HOJE: estimar entregas de dias que ainda não
|
||||
# aconteceram não faz sentido (o período pode ir até o fim do mês).
|
||||
def carregar_estimado(nome)
|
||||
@estimado_inicio = @inicio
|
||||
@estimado_fim = [@fim, Date.current].min
|
||||
|
||||
if @estimado_fim < @estimado_inicio
|
||||
@entregas_concluidas = @entregas_falhadas = @entregas_mes = 0
|
||||
@valor_estimado = 0
|
||||
return
|
||||
end
|
||||
|
||||
@estimado_inicio = Date.current.beginning_of_month
|
||||
@estimado_fim = Date.current
|
||||
atendidas = Entrega.atendidas.da_conta_gade
|
||||
.do_motorista(nome)
|
||||
.no_periodo_checkout(@estimado_inicio, @estimado_fim)
|
||||
@@ -82,44 +28,33 @@ module Motorista
|
||||
@entregas_falhadas = atendidas.falhadas.count
|
||||
@entregas_mes = @entregas_concluidas + @entregas_falhadas
|
||||
@valor_estimado = @entregas_mes * Configuracao.preco_entrega
|
||||
end
|
||||
|
||||
# Card FECHADO + composição: o mesmo service do modo "Por motorista" do
|
||||
# admin, recortado neste motorista — os dois lados mostram o mesmo número.
|
||||
def carregar_fechado(nome)
|
||||
@totais = Analytics::TotaisPorMotorista.new(inicio: @inicio, fim: @fim, motorista: nome)
|
||||
@consolidacoes = @totais.consolidacoes_de(nome)
|
||||
@valor_consolidado = @totais.total_geral
|
||||
end
|
||||
|
||||
# Primeira e última data de fechamento do motorista — alimenta o atalho
|
||||
# "Tudo" com datas reais em vez de uma faixa inventada.
|
||||
def faixa_completa(nome)
|
||||
escopo = Consolidacao.ativas.finalizada
|
||||
# Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas
|
||||
@minhas_consolidacoes = Consolidacao.ativas
|
||||
.joins(:consolidacao_motoristas)
|
||||
.where(consolidacao_motoristas: { motorista_nome: nome, arquivado_em: nil })
|
||||
inicio = escopo.minimum(:data_inicio)
|
||||
fim = escopo.maximum(:data_fim)
|
||||
return nil if inicio.nil? || fim.nil?
|
||||
.recentes
|
||||
.distinct
|
||||
|
||||
[inicio.to_date, fim.to_date]
|
||||
@valor_consolidado = ConsolidacaoMotorista.ativos
|
||||
.joins(:consolidacao)
|
||||
.where(motorista_nome: nome,
|
||||
consolidacoes: { status: :finalizada, deleted_at: nil })
|
||||
.sum(:valor_total)
|
||||
|
||||
# Aviso in-app: pagamentos confirmados nos últimos 7 dias (notificação na
|
||||
# própria página do motorista, além de WhatsApp/email).
|
||||
@pagamentos_recentes = ConsolidacaoMotorista
|
||||
.joins(:consolidacao)
|
||||
.where(motorista_nome: nome,
|
||||
consolidacoes: { status: :finalizada, deleted_at: nil })
|
||||
.where.not(pago_em: nil)
|
||||
.where('pago_em >= ?', 7.days.ago)
|
||||
.order(pago_em: :desc)
|
||||
.includes(:consolidacao)
|
||||
end
|
||||
|
||||
# Default: mês corrente INTEIRO (fim = último dia do mês) — o mês é a unidade
|
||||
# de fechamento. Mesma regra do lado do admin.
|
||||
def periodo_selecionado
|
||||
inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month
|
||||
fim = parse_data(params[:fim]) || Date.current.end_of_month
|
||||
inicio, fim = fim, inicio if fim < inicio
|
||||
[inicio, fim]
|
||||
end
|
||||
|
||||
def parse_data(str)
|
||||
return nil if str.blank?
|
||||
Date.parse(str)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
private
|
||||
|
||||
def garantir_motorista
|
||||
return if current_user.motorista?
|
||||
|
||||
@@ -16,7 +16,6 @@ class OperacoesDashboardController < ApplicationController
|
||||
authorize :dashboard, :operacoes?
|
||||
|
||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||
@periodo_explicito = periodo_explicito?
|
||||
@modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao'
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
@tabelas_validas = Operacao.nomes_validos
|
||||
@@ -27,7 +26,6 @@ class OperacoesDashboardController < ApplicationController
|
||||
when 'global'
|
||||
# Global: agrega TODAS as operações dentro da faixa de datas escolhida.
|
||||
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
|
||||
@fora_operacao = Analytics::NotasForaOperacao.new(inicio: @periodo_inicio, fim: @periodo_fim)
|
||||
when 'comparar'
|
||||
# Comparar: cada operação inteira (sem filtro de data — só vale no Global).
|
||||
@op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0]
|
||||
@@ -35,20 +33,9 @@ class OperacoesDashboardController < ApplicationController
|
||||
@metricas_a = montar([@op_a]) if @op_a
|
||||
@metricas_b = montar([@op_b]) if @op_b
|
||||
else
|
||||
# Operação única: o recorte é SEMPRE a operação inteira, mesmo com
|
||||
# inicio/fim na URL. É o que confere com os documentos físicos e com a
|
||||
# planilha entregue ao cliente (Analytics::PlanilhaEntregas também não
|
||||
# filtra data). Recortar pelo período derrubava as entregas que a operação
|
||||
# executou fora do mês do nome dela — EMAD AGO 2026 rodou de 30/07 a
|
||||
# 12/08, e "01/08 → hoje" mostrava 1146 das 2021 NFs.
|
||||
#
|
||||
# O período escolhido continua tendo função aqui: alimenta as "Notas fora
|
||||
# da operação" e aparece no cabeçalho como REFERÊNCIA (quantas NFs da
|
||||
# operação caíram na faixa), para comparar com o dashboard financeiro sem
|
||||
# mexer no número que o cliente confere.
|
||||
# Operação única: a operação inteira (a data vem dos próprios dados).
|
||||
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
|
||||
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
|
||||
@fora_operacao = Analytics::NotasForaOperacao.new(inicio: @periodo_inicio, fim: @periodo_fim) if @periodo_explicito
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,7 +46,6 @@ class OperacoesDashboardController < ApplicationController
|
||||
authorize :dashboard, :operacoes?
|
||||
|
||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||
@periodo_explicito = periodo_explicito?
|
||||
@modo = params[:modo] == 'global' ? 'global' : 'operacao'
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
@tabelas_validas = Operacao.nomes_validos
|
||||
@@ -69,8 +55,6 @@ class OperacoesDashboardController < ApplicationController
|
||||
if @modo == 'global'
|
||||
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
|
||||
else
|
||||
# Mesma regra do #index: operação = operação inteira (a planilha espelho
|
||||
# tem que bater com a que é entregue ao cliente).
|
||||
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
|
||||
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
|
||||
end
|
||||
@@ -82,9 +66,7 @@ class OperacoesDashboardController < ApplicationController
|
||||
# Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado
|
||||
# do rastreio — o modelo que é entregue ao cliente.
|
||||
def baixar_planilha
|
||||
# Chave própria: aqui o dado do cliente SAI do sistema (.xlsx), o que é
|
||||
# diferente de olhar os números na tela.
|
||||
authorize :dashboard, :exportar?
|
||||
authorize :dashboard, :operacoes?
|
||||
|
||||
tabela = params[:operacao].to_s
|
||||
unless Operacao.valida?(tabela)
|
||||
@@ -118,12 +100,6 @@ class OperacoesDashboardController < ApplicationController
|
||||
@espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || []
|
||||
end
|
||||
|
||||
# Período escolhido pelo operador (veio na URL) x default da tela. No modo
|
||||
# Operação ele não recorta os KPIs — ver o comentário no #index.
|
||||
def periodo_explicito?
|
||||
params[:inicio].present? || params[:fim].present?
|
||||
end
|
||||
|
||||
def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
|
||||
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
|
||||
end
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# app/controllers/paginas_controller.rb
|
||||
#
|
||||
# Telas do sistema que não pertencem a nenhum recurso.
|
||||
class PaginasController < ApplicationController
|
||||
# Quem chega aqui está autenticado, mas o perfil de acesso não libera nenhuma
|
||||
# tela. Sem esta página, o "acesso negado" mandaria de volta para a raiz — que
|
||||
# também nega — e a pessoa ficaria num loop de redirecionamento.
|
||||
def sem_acesso
|
||||
skip_authorization
|
||||
@perfil = current_user.perfil_acesso
|
||||
end
|
||||
end
|
||||
@@ -5,19 +5,6 @@ class Users::SessionsController < Devise::SessionsController
|
||||
# GET /users/sign_in
|
||||
def new
|
||||
@pin_login = params[:pin].present?
|
||||
|
||||
# "Para continuar, faça login." NÃO é erro: é o que o Devise sempre grava
|
||||
# quando alguém abre o sistema deslogado (raiz -> authenticate_user! -> aqui).
|
||||
# Como `flash[:alert]` ele saía em vermelho DUAS vezes — o toast do layout e
|
||||
# a caixa desta tela — e a primeira entrada do dia parecia falha do sistema
|
||||
# (foi relatado como bug: "a primeira vez que entra dá a tela de erro").
|
||||
#
|
||||
# A mensagem é descartada: a tela já é o formulário de login, ela não
|
||||
# acrescenta nada. NÃO troque isso por apagar todo `flash[:alert]` no `new`:
|
||||
# senha errada, conta inativa e `timeout` ("Sua sessão expirou") chegam por
|
||||
# aqui pelo mesmo caminho, e essas a pessoa PRECISA ver em vermelho.
|
||||
flash.delete(:alert) if flash[:alert] == I18n.t('devise.failure.unauthenticated', default: nil)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
@@ -33,13 +20,14 @@ class Users::SessionsController < Devise::SessionsController
|
||||
|
||||
protected
|
||||
|
||||
# Para onde a pessoa vai depois de entrar. NÃO é mais "motorista → painel,
|
||||
# resto → dashboard": com perfis de acesso, alguém pode não ter o dashboard e
|
||||
# cairia direto numa tela negada. `home_rota` devolve a primeira tela permitida.
|
||||
def after_sign_in_path_for(resource)
|
||||
session[:mostrar_splash] = true # dispara a animação de abertura uma vez
|
||||
rota = resource.home_rota
|
||||
rota ? public_send(rota) : sem_acesso_path
|
||||
case resource.role
|
||||
when 'motorista'
|
||||
motorista_dashboard_path
|
||||
else
|
||||
dashboard_path
|
||||
end
|
||||
end
|
||||
|
||||
def after_sign_out_path_for(resource_or_scope)
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// app/javascript/controllers/carrossel_controller.js
|
||||
// Stimulus — paginação em "carrossel" para listas longas: mostra uma página de
|
||||
// itens por vez, com setas grandes e o indicador "1–5 de 12".
|
||||
//
|
||||
// TAMANHO DE PÁGINA POR TELA (é o ponto do controller):
|
||||
// • celular (até 640px) → 5 itens — cada card ocupa muita altura;
|
||||
// 10 já viravam rolagem infinita;
|
||||
// • até 1366px → 10 itens — tablet e notebook pequeno;
|
||||
// • acima disso → lista INTEIRA, sem setas. Em monitor grande a
|
||||
// lista cabe e esconder item só atrapalha (o
|
||||
// Ctrl+F do navegador não acha o que está oculto).
|
||||
//
|
||||
// O corte é reavaliado no resize/rotação: girar o celular ou encostar a janela
|
||||
// na lateral já muda o comportamento, e o item que estava no topo continua na
|
||||
// tela (a posição é guardada pelo ÍNDICE do item, não pelo número da página).
|
||||
//
|
||||
// Os itens já vêm todos no HTML; aqui só escondemos os de fora da página. Por
|
||||
// isso não serve para listas de centenas de itens (aí é paginação no servidor).
|
||||
//
|
||||
// Uso:
|
||||
// <div data-controller="carrossel"
|
||||
// data-carrossel-tamanho-value="10" <- itens por página
|
||||
// data-carrossel-tamanho-compacto-value="5" <- itens no celular
|
||||
// data-carrossel-largura-compacta-value="640" <- até aqui é "celular"
|
||||
// data-carrossel-max-largura-value="1366"> <- acima disso, mostra tudo
|
||||
// <div data-carrossel-target="item">…</div>
|
||||
// <%= render 'shared/carrossel_controles' %>
|
||||
// </div>
|
||||
//
|
||||
// Um item com data-carrossel-inicial="true" (ex.: o motorista com a composição
|
||||
// aberta) define em qual página o carrossel abre — senão o clique dele "sumia".
|
||||
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["item", "controles", "indicador", "anterior", "proximo"]
|
||||
static values = {
|
||||
tamanho: { type: Number, default: 10 },
|
||||
tamanhoCompacto: { type: Number, default: 5 },
|
||||
larguraCompacta: { type: Number, default: 640 },
|
||||
maxLargura: { type: Number, default: 1366 }
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Âncora = índice do primeiro item da página atual. Guardar o ÍNDICE (e não
|
||||
// o número da página) é o que mantém o mesmo item na tela quando o tamanho
|
||||
// da página muda junto com a largura.
|
||||
this.ancora = this.indiceInicial()
|
||||
this._aoRedimensionar = () => this.render()
|
||||
window.addEventListener("resize", this._aoRedimensionar)
|
||||
this.render()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener("resize", this._aoRedimensionar)
|
||||
}
|
||||
|
||||
proxima() { this.ir(this.pagina + 1) }
|
||||
anterior() { this.ir(this.pagina - 1) }
|
||||
|
||||
ir(destino) {
|
||||
const pagina = Math.min(Math.max(destino, 0), this.totalPaginas - 1)
|
||||
this.ancora = pagina * this.tamanho
|
||||
this.render()
|
||||
// Volta ao topo da lista: sem isso, na página seguinte o leitor continua
|
||||
// vendo o fim da anterior e parece que nada mudou.
|
||||
this.element.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
|
||||
get tamanho() {
|
||||
return window.innerWidth <= this.larguraCompactaValue
|
||||
? this.tamanhoCompactoValue
|
||||
: this.tamanhoValue
|
||||
}
|
||||
|
||||
// Tela grande: sem paginação (a lista inteira aparece).
|
||||
get paginando() {
|
||||
return window.innerWidth <= this.maxLarguraValue &&
|
||||
this.itemTargets.length > this.tamanho
|
||||
}
|
||||
|
||||
get totalPaginas() {
|
||||
return Math.max(Math.ceil(this.itemTargets.length / this.tamanho), 1)
|
||||
}
|
||||
|
||||
get pagina() {
|
||||
return Math.min(Math.floor(this.ancora / this.tamanho), this.totalPaginas - 1)
|
||||
}
|
||||
|
||||
indiceInicial() {
|
||||
const i = this.itemTargets.findIndex((el) => el.dataset.carrosselInicial === "true")
|
||||
return i < 0 ? 0 : i
|
||||
}
|
||||
|
||||
render() {
|
||||
const total = this.itemTargets.length
|
||||
|
||||
if (!this.paginando) {
|
||||
this.itemTargets.forEach((item) => item.classList.remove("hidden"))
|
||||
this.controlesTargets.forEach((el) => el.classList.add("hidden"))
|
||||
return
|
||||
}
|
||||
|
||||
const inicio = this.pagina * this.tamanho
|
||||
const fim = inicio + this.tamanho
|
||||
|
||||
this.itemTargets.forEach((item, i) => {
|
||||
item.classList.toggle("hidden", i < inicio || i >= fim)
|
||||
})
|
||||
|
||||
if (!this.hasControlesTarget) return
|
||||
this.controlesTargets.forEach((el) => el.classList.remove("hidden"))
|
||||
|
||||
// "1–5 de 12" e não "página 1 de 3": quem lê quer saber quantos itens
|
||||
// existem, não quantas páginas a tela tem.
|
||||
const rotulo = `${inicio + 1}–${Math.min(fim, total)} de ${total}`
|
||||
this.indicadorTargets.forEach((el) => { el.textContent = rotulo })
|
||||
|
||||
// Plural: o mesmo controle aparece em dois layouts (setas laterais em tela
|
||||
// com espaço, barra flutuante no rodapé no celular) e os dois precisam
|
||||
// desabilitar juntos.
|
||||
this.anteriorTargets.forEach((b) => this.alternar(b, this.pagina === 0))
|
||||
this.proximoTargets.forEach((b) => this.alternar(b, this.pagina >= this.totalPaginas - 1))
|
||||
}
|
||||
|
||||
// Botão de ponta: desabilitado de verdade (não só apagado), para o toque não
|
||||
// "funcionar sem fazer nada".
|
||||
alternar(botao, desligado) {
|
||||
botao.disabled = desligado
|
||||
botao.classList.toggle("opacity-30", desligado)
|
||||
botao.classList.toggle("pointer-events-none", desligado)
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Editor do romaneio: salva cada campo assim que ele muda e reatualiza a prévia
|
||||
// (que é o PDF de verdade, num iframe).
|
||||
//
|
||||
// Por que salvar POR CAMPO e não com um submit de formulário: `campos_editados`
|
||||
// precisa registrar exatamente o campo que um humano tocou. Um submit em bloco não
|
||||
// distingue "redigitei o mesmo valor" de "nunca olhei" — marcaria os 5 campos das 29
|
||||
// linhas como editados, e a reimportação seguinte preservaria endereços velhos no
|
||||
// plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com
|
||||
// escrita por campo.
|
||||
export default class extends Controller {
|
||||
// `overlay` é o <details> da prévia (nome mantido para não renomear em vão);
|
||||
// `caixaPreview` saiu junto com o modal.
|
||||
static targets = ["celula", "preview", "itemVeiculo", "motorista", "rotulo", "status", "linha", "busca",
|
||||
"contador", "overlay", "tituloPreview"]
|
||||
static values = { url: String, pdfUrl: String, veiculo: String }
|
||||
|
||||
// Recarregar o PDF a cada tecla travaria a tela; 600ms depois do último save é o
|
||||
// suficiente para parecer imediato sem regerar o documento à toa.
|
||||
static ATRASO_PREVIEW = 600
|
||||
|
||||
connect() {
|
||||
this.timerPreview = null
|
||||
// A prévia nasce fechada e DESATUALIZADA: o PDF só é gerado na primeira vez
|
||||
// que alguém pede para ver. Antes o iframe era montado a cada carregamento da
|
||||
// tela e gerava um PDF que muitas vezes ninguém olhava.
|
||||
this.previewDesatualizado = true
|
||||
// Com 72 carros a lista rola; sem isto, ao trocar de veículo o selecionado
|
||||
// pode nascer fora da área visível e parece que a seleção não pegou.
|
||||
const atual = this.itemVeiculoTargets.find((a) => a.dataset.atual === "1")
|
||||
if (atual) atual.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.timerPreview) clearTimeout(this.timerPreview)
|
||||
}
|
||||
|
||||
// ── Edição ──────────────────────────────────────────────────────────────
|
||||
salvarCelula(event) {
|
||||
const input = event.currentTarget
|
||||
// Sair do campo sem mudar nada não é edição — marcar como editado aqui
|
||||
// congelaria o valor contra a próxima reimportação sem o operador querer.
|
||||
if (input.value === input.dataset.original) return
|
||||
|
||||
this.gravar(input.dataset.linhaId, input.dataset.campo, input.value, input)
|
||||
}
|
||||
|
||||
restaurar(event) {
|
||||
const botao = event.currentTarget
|
||||
const { linhaId, campo } = botao.dataset
|
||||
const input = this.celulaTargets.find(
|
||||
(c) => c.dataset.linhaId === linhaId && c.dataset.campo === campo
|
||||
)
|
||||
// valor null no corpo = "devolve o que o plano trouxe e destrava o campo"
|
||||
this.gravar(linhaId, campo, null, input, botao)
|
||||
}
|
||||
|
||||
alternarAparelho(event) {
|
||||
const botao = event.currentTarget
|
||||
const novo = botao.dataset.valor === "SIM" ? "" : "SIM"
|
||||
this.gravar(botao.dataset.linhaId, "aparelho", novo, null, null, (dados) => {
|
||||
botao.dataset.valor = dados.valor
|
||||
botao.textContent = dados.valor || "—"
|
||||
const estilo = dados.valor === "SIM"
|
||||
? "bg-green-600/20 text-green-300 border-green-600/40"
|
||||
: "bg-[#1a1a1a] text-gray-500 border-white/10"
|
||||
botao.className = `${estilo} w-full min-h-[40px] rounded-lg border font-semibold text-xs hover:border-orange-500 transition-colors`
|
||||
this.marcar(botao.dataset.linhaId, "aparelho", dados.editado)
|
||||
})
|
||||
}
|
||||
|
||||
salvarRotulo() {
|
||||
this.enviar(`${this.urlValue}/atualizar_rotulo`, { rotulo_plano: this.rotuloTarget.value })
|
||||
.then(() => this.agendarPreview())
|
||||
}
|
||||
|
||||
salvarMotorista() {
|
||||
// Não mexe na prévia: o nome do motorista não sai impresso — a linha do
|
||||
// rodapé é preenchida à mão na retirada.
|
||||
this.enviar(`${this.urlValue}/atualizar_veiculo`, {
|
||||
veiculo: this.veiculoValue,
|
||||
motorista: this.motoristaTarget.value
|
||||
})
|
||||
}
|
||||
|
||||
// ── Navegação e filtro ──────────────────────────────────────────────────
|
||||
// A lista de veículos são LINKS de verdade (o veículo vive na query string), então
|
||||
// clicar já navega sem JS. As setas só precisam achar o vizinho na mesma lista —
|
||||
// é o que o operador faz o dia inteiro: percorrer carro a carro.
|
||||
anterior() { this.mover(-1) }
|
||||
proximo() { this.mover(1) }
|
||||
|
||||
mover(passo) {
|
||||
const itens = this.itemVeiculoTargets
|
||||
if (itens.length === 0) return
|
||||
|
||||
const atual = itens.findIndex((a) => a.dataset.atual === "1")
|
||||
const destino = (atual === -1 ? 0 : atual) + passo
|
||||
// Sem dar a volta de propósito: chegar na ponta e voltar ao primeiro carro faz
|
||||
// o operador reimprimir folha que já separou, sem perceber.
|
||||
if (destino < 0 || destino >= itens.length) return
|
||||
|
||||
window.location.href = itens[destino].href
|
||||
}
|
||||
|
||||
filtrar() {
|
||||
const termo = this.hasBuscaTarget ? this.buscaTarget.value.trim().toLowerCase() : ""
|
||||
let visiveis = 0
|
||||
|
||||
this.linhaTargets.forEach((linha) => {
|
||||
const casa = termo === "" || linha.dataset.busca.includes(termo)
|
||||
linha.hidden = !casa
|
||||
if (casa) visiveis += 1
|
||||
})
|
||||
|
||||
// Sem o contador, uma busca que não casa com nada deixa a tabela vazia e
|
||||
// parece plano vazio — o operador acha que perdeu o romaneio.
|
||||
if (!this.hasContadorTarget) return
|
||||
const total = this.linhaTargets.length
|
||||
const plural = visiveis === 1 ? "parada" : "paradas"
|
||||
this.contadorTarget.textContent = termo === ""
|
||||
? `${total} ${total === 1 ? "parada" : "paradas"} nesta folha`
|
||||
: `${visiveis} de ${total} ${plural} — filtrando por "${termo}"`
|
||||
}
|
||||
|
||||
limparBusca() {
|
||||
if (!this.hasBuscaTarget) return
|
||||
this.buscaTarget.value = ""
|
||||
this.filtrar()
|
||||
this.buscaTarget.focus()
|
||||
}
|
||||
|
||||
// ── Prévia (pop-up; estado na URL, ?previa=1) ───────────────────────────
|
||||
// Abrir, fechar e trocar de folha são LINKS comuns — nada disso depende deste
|
||||
// controller, e é o que mantém a prévia viva quando o JS não carrega. O
|
||||
// overlay só existe no DOM quando a prévia está aberta, então a presença do
|
||||
// alvo já é a resposta. O que sobra aqui é recarregar a folha após uma edição.
|
||||
previewAberta() {
|
||||
return this.hasOverlayTarget
|
||||
}
|
||||
|
||||
carregarPreview() {
|
||||
if (!this.hasPreviewTarget) return
|
||||
|
||||
// Cache-bust em vez de reload(): reload reinicializa o visualizador de PDF do
|
||||
// navegador e a tela pisca a cada salvamento.
|
||||
this.previewTarget.src =
|
||||
`${this.pdfUrlValue}?veiculo=${encodeURIComponent(this.veiculoValue)}&t=${Date.now()}`
|
||||
this.previewDesatualizado = false
|
||||
}
|
||||
|
||||
// Chamada depois de cada gravação. Com o pop-up FECHADO não gera PDF nenhum —
|
||||
// o `loading="lazy"` do iframe já garante isso, e recarregar o src de um
|
||||
// iframe escondido forçaria o servidor a montar um PDF que ninguém vai ver.
|
||||
atualizarPreview() {
|
||||
if (!this.previewAberta()) {
|
||||
this.previewDesatualizado = true
|
||||
return
|
||||
}
|
||||
this.carregarPreview()
|
||||
}
|
||||
|
||||
|
||||
// ── Infra ───────────────────────────────────────────────────────────────
|
||||
gravar(linhaId, campo, valor, input, botao, aoConcluir) {
|
||||
this.status("Salvando…")
|
||||
|
||||
this.enviar(`${this.urlValue}/atualizar_linha`, { linha_id: linhaId, campo, valor })
|
||||
.then((dados) => {
|
||||
if (!dados || !dados.ok) return
|
||||
if (input) {
|
||||
// Escrevemos de volta o valor QUE O SERVIDOR GRAVOU: a escrita é
|
||||
// last-write-wins, e assim uma sobreposição de duas abas fica visível em
|
||||
// vez de silenciosa.
|
||||
input.value = dados.valor
|
||||
input.dataset.original = dados.valor
|
||||
}
|
||||
this.marcar(linhaId, campo, dados.editado)
|
||||
if (aoConcluir) aoConcluir(dados)
|
||||
this.status("Salvo")
|
||||
this.agendarPreview()
|
||||
})
|
||||
.catch(() => this.status("Não deu para salvar — tente de novo"))
|
||||
}
|
||||
|
||||
marcar(linhaId, campo, editado) {
|
||||
const alvo = this.element.querySelector(`[data-romaneio-marcador="${linhaId}-${campo}"]`)
|
||||
if (!alvo) return
|
||||
|
||||
if (!editado) {
|
||||
alvo.innerHTML = ""
|
||||
return
|
||||
}
|
||||
alvo.innerHTML =
|
||||
`<button type="button" title="Editado — não será sobrescrito na reimportação. Clique para restaurar o valor do plano." ` +
|
||||
`data-action="romaneio#restaurar" data-linha-id="${linhaId}" data-campo="${campo}" ` +
|
||||
`class="text-orange-400 hover:text-orange-200 text-xs leading-none">●</button>`
|
||||
}
|
||||
|
||||
agendarPreview() {
|
||||
if (this.timerPreview) clearTimeout(this.timerPreview)
|
||||
this.timerPreview = setTimeout(() => this.atualizarPreview(), this.constructor.ATRASO_PREVIEW)
|
||||
}
|
||||
|
||||
enviar(url, corpo) {
|
||||
return fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
body: JSON.stringify(corpo)
|
||||
}).then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
||||
}
|
||||
|
||||
status(texto) {
|
||||
if (this.hasStatusTarget) this.statusTarget.textContent = texto
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
# app/jobs/detectar_mudancas_operacao_job.rb
|
||||
#
|
||||
# Compara os números de cada operação com o retrato anterior (OperacaoSnapshot)
|
||||
# e dispara `operacao_alterada` no que mudou.
|
||||
#
|
||||
# POR QUE VARREDURA e não gatilho no código: a maior parte das mudanças da
|
||||
# operação NÃO passa por aqui — ela acontece no SimpliRoute e chega pelo sync do
|
||||
# espelho, que é read-only do nosso lado. Sem comparar retratos, "o que mudou"
|
||||
# seria só o que o próprio sistema editou, que é a minoria dos casos.
|
||||
#
|
||||
# A PRIMEIRA execução de cada operação só grava o retrato e não avisa nada: sem
|
||||
# retrato anterior, toda operação existente pareceria "nova" e o primeiro deploy
|
||||
# dispararia uma mensagem por operação cadastrada.
|
||||
class DetectarMudancasOperacaoJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
return unless EventoNotificacao.ativos.exists?(gatilho: 'operacao_alterada')
|
||||
|
||||
Operacao.nomes_validos.each { |tabela| verificar(tabela) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[MudancasOperacao] #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verificar(tabela)
|
||||
atuais = contar(tabela)
|
||||
snapshot = OperacaoSnapshot.para(tabela)
|
||||
|
||||
if snapshot.new_record?
|
||||
snapshot.registrar!(atuais)
|
||||
return
|
||||
end
|
||||
|
||||
diferenca = snapshot.diferenca(atuais)
|
||||
return if diferenca.empty?
|
||||
|
||||
Notificacao::Gatilhos.operacao_alterada(
|
||||
operacao: Operacao.label(tabela),
|
||||
o_que_mudou: OperacaoSnapshot.descrever(diferenca)
|
||||
)
|
||||
snapshot.registrar!(atuais)
|
||||
rescue StandardError => e
|
||||
# Uma operação problemática não pode impedir a varredura das demais.
|
||||
Rails.logger.error("[MudancasOperacao] #{tabela}: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
# Sem filtro de data: a comparação é do estado ATUAL da operação inteira, que
|
||||
# é o que muda quando uma NF entra, sai ou troca de status.
|
||||
#
|
||||
# ⚠️ CUSTO: OperacaoMetricas carrega as visitas da operação em memória para
|
||||
# deduplicar por NF. Uma operação mensal tem alguns milhares de linhas, e o
|
||||
# job varre TODAS as tabelas a cada execução (8×/dia pelo schedule). É o
|
||||
# preço de reusar a mesma contagem do dashboard em vez de escrever um SQL
|
||||
# paralelo que divergiria dele. Se passar a incomodar, o caminho é restringir
|
||||
# a varredura às operações do mês corrente e do anterior — as antigas não
|
||||
# mudam mais.
|
||||
def contar(tabela)
|
||||
m = Analytics::OperacaoMetricas.new(tabelas: [tabela])
|
||||
{ total_notas: m.total, entregues: m.sucesso,
|
||||
nao_entregues: m.recusas, pendentes: m.pendentes }
|
||||
end
|
||||
end
|
||||
@@ -1,48 +0,0 @@
|
||||
# app/jobs/disparar_eventos_agendados_job.rb
|
||||
#
|
||||
# Varre os eventos de gatilho `agendado` e dispara os que venceram. Roda de hora
|
||||
# em hora pelo cron do container (config/schedule.rb).
|
||||
#
|
||||
# POR QUE DE HORA EM HORA e não "no horário exato": o evento guarda hora e
|
||||
# frequência escolhidas na tela, e essas podem mudar a qualquer momento — reler
|
||||
# o crontab a cada edição seria acoplar a UI ao cron do sistema. Uma varredura
|
||||
# horária resolve com uma linha fixa no schedule, e `EventoNotificacao#vencido?`
|
||||
# garante que cada evento dispara UMA vez por dia (ele grava ultimo_disparo_em).
|
||||
#
|
||||
# PERÍODO do resumo — recortes FECHADOS, para o número não mudar depois de
|
||||
# enviado:
|
||||
# diária → o dia anterior
|
||||
# semanal → os 7 dias anteriores
|
||||
class DispararEventosAgendadosJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(agora = Time.current)
|
||||
EventoNotificacao.ativos.where(gatilho: 'agendado').find_each do |evento|
|
||||
next unless evento.vencido?(agora)
|
||||
|
||||
inicio, fim = janela(evento, agora)
|
||||
resumo = Analytics::ResumoOperacao.new(inicio: inicio, fim: fim)
|
||||
|
||||
# Resumo zerado (feriado, fim de semana) só treinaria o leitor a ignorar a
|
||||
# mensagem. Marca como disparado assim mesmo, senão tentaria de hora em
|
||||
# hora até o dia virar.
|
||||
if resumo.vazio?
|
||||
evento.update_columns(ultimo_disparo_em: agora)
|
||||
Rails.logger.info("[Agendados] #{evento.chave}: período sem movimento, nada enviado")
|
||||
next
|
||||
end
|
||||
|
||||
Notificacao::Gatilhos.resumo_agendado(evento, inicio: inicio, fim: fim)
|
||||
evento.update_columns(ultimo_disparo_em: agora)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Agendados] #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def janela(evento, agora)
|
||||
hoje = agora.to_date
|
||||
evento.frequencia == 'semanal' ? [hoje - 7, hoje - 1] : [hoje - 1, hoje - 1]
|
||||
end
|
||||
end
|
||||
@@ -1,26 +0,0 @@
|
||||
# app/jobs/notificacao_job.rb
|
||||
#
|
||||
# Entrega das notificações fora da requisição web.
|
||||
#
|
||||
# POR QUE JOB: o envio de WhatsApp pausa entre uma mensagem e outra (o canal por
|
||||
# QR bane número que dispara em rajada). Um `sleep` de 5s vezes 30 contatos
|
||||
# dentro do controller pendura o worker Puma por dois minutos e meio.
|
||||
#
|
||||
# ⚠️ O adapter é o :async do Rails (fila em memória, igual ao `deliver_later` que
|
||||
# o app já usa): job pendente se perde se o container reiniciar. Para
|
||||
# notificação isso é aceitável — o log em notificacao_envios mostra o que
|
||||
# ficou 'pendente' e a tela permite reenviar.
|
||||
class NotificacaoJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(evento_id, dados = {}, assunto = nil, corpo = nil, envolvido_id = nil)
|
||||
evento = EventoNotificacao.find_by(id: evento_id)
|
||||
return if evento.nil? || !evento.ativo?
|
||||
|
||||
envolvido = envolvido_id && User.find_by(id: envolvido_id)
|
||||
Notificacao::Despachante.new(evento, dados: dados, assunto: assunto,
|
||||
corpo: corpo, envolvido: envolvido).executar
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[NotificacaoJob] evento #{evento_id}: #{e.class}: #{e.message}")
|
||||
end
|
||||
end
|
||||
@@ -1,18 +0,0 @@
|
||||
# app/mailers/notificacao_mailer.rb
|
||||
#
|
||||
# E-mail genérico das notificações configuráveis: assunto e corpo vêm prontos do
|
||||
# editor, não de uma view por evento (como no ConsolidacaoMailer). É o que
|
||||
# permite o ADM criar eventos novos sem precisar de código.
|
||||
class NotificacaoMailer < ApplicationMailer
|
||||
# `html: true` = o corpo já vem montado pelo Notificacao::Renderizador, que
|
||||
# escapa todo texto do editor e toda variável. `html: false` = texto puro
|
||||
# digitado, formatado com simple_format (que escapa).
|
||||
#
|
||||
# ⚠️ Nunca chamar com html: true para texto que não passou pelo renderizador —
|
||||
# seria injeção de HTML no e-mail.
|
||||
def mensagem(destino, assunto, corpo, html: false)
|
||||
@corpo = corpo.to_s
|
||||
@html = html
|
||||
mail(to: destino, subject: assunto.presence || 'Aviso')
|
||||
end
|
||||
end
|
||||
@@ -19,10 +19,6 @@ class Configuracao < ApplicationRecord
|
||||
notificacao_email
|
||||
empresa_nome
|
||||
empresa_logo
|
||||
# Logo impresso no romaneio (documento do CLIENTE — hoje a GADE, por isso é
|
||||
# separado do `empresa_logo`, que é a marca da Reem). Guarda só o NOME do
|
||||
# arquivo; o binário vive em storage/logos/. Vazio = usa public/logo-gade.png.
|
||||
romaneio_logo
|
||||
].freeze
|
||||
|
||||
CHAVES_MOEDA = %w[
|
||||
|
||||
@@ -11,15 +11,9 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
|
||||
self.table_name = 'configuracao_notificacoes'
|
||||
|
||||
atributo_cifrado :smtp_password, :twilio_auth_token, :baileys_token
|
||||
atributo_cifrado :smtp_password, :twilio_auth_token
|
||||
|
||||
AUTENTICACOES = %w[plain login cram_md5].freeze
|
||||
|
||||
# Quem entrega o WhatsApp. `baileys` é a ponte por QR code (container
|
||||
# `whatsapp`), gratuita e ilimitada, porém NÃO OFICIAL — a Meta pode banir o
|
||||
# número. `twilio` é o canal oficial pago que existia antes.
|
||||
PROVEDORES_WHATSAPP = %w[baileys twilio].freeze
|
||||
BAILEYS_URL_PADRAO = 'http://whatsapp:3001'.freeze
|
||||
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
|
||||
REMETENTE_PADRAO = 'noreply@gade.com.br'
|
||||
|
||||
@@ -34,16 +28,9 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
validates :email_admin, format: { with: EMAIL_RE }, allow_blank: true
|
||||
validate :senha_smtp_presente, if: :smtp_ativo?
|
||||
|
||||
validates :whatsapp_provedor, inclusion: { in: PROVEDORES_WHATSAPP }
|
||||
validates :whatsapp_intervalo_segundos,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0,
|
||||
less_than_or_equal_to: 300 }
|
||||
|
||||
# As credenciais do Twilio só são exigidas quando ele É o provedor escolhido.
|
||||
validates :twilio_account_sid, presence: true, if: :twilio_exigido?
|
||||
validates :twilio_from, presence: true, if: :twilio_exigido?
|
||||
validate :token_twilio_presente, if: :twilio_exigido?
|
||||
validate :baileys_configuravel, if: :baileys_exigido?
|
||||
validates :twilio_account_sid, presence: true, if: :whatsapp_ativo?
|
||||
validates :twilio_from, presence: true, if: :whatsapp_ativo?
|
||||
validate :token_twilio_presente, if: :whatsapp_ativo?
|
||||
|
||||
# ── Singleton ───────────────────────────────────────────────
|
||||
def self.instancia
|
||||
@@ -115,37 +102,6 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
email_notificacoes_ativo?
|
||||
end
|
||||
|
||||
# ── WhatsApp: escolha de provedor ───────────────────────────
|
||||
def baileys? = whatsapp_provedor.to_s == 'baileys'
|
||||
def twilio? = whatsapp_provedor.to_s == 'twilio'
|
||||
|
||||
def twilio_exigido? = whatsapp_ativo? && twilio?
|
||||
def baileys_exigido? = whatsapp_ativo? && baileys?
|
||||
|
||||
# URL e token da ponte: banco > .env, mesma hierarquia do resto da tela.
|
||||
#
|
||||
# `WHATSAPP_URL` é o nome atual — a tela e o .env.example falam em "ponte do
|
||||
# WhatsApp", não no nome da biblioteca. `BAILEYS_URL` continua sendo lido para
|
||||
# não quebrar quem já tem a variável antiga no .env em produção.
|
||||
def baileys_url_efetiva
|
||||
baileys_url.presence || ENV['WHATSAPP_URL'].presence ||
|
||||
ENV['BAILEYS_URL'].presence || BAILEYS_URL_PADRAO
|
||||
end
|
||||
|
||||
def baileys_token_efetivo
|
||||
baileys_token.presence || ENV['WHATSAPP_TOKEN'].presence
|
||||
end
|
||||
|
||||
def baileys_pronto?
|
||||
whatsapp_ativo? && baileys? && baileys_token_efetivo.present?
|
||||
end
|
||||
|
||||
# Segundos entre uma mensagem e a seguinte. Disparo em rajada para dezenas de
|
||||
# contatos é a forma mais rápida de o número ser banido no canal não oficial.
|
||||
def intervalo_envio
|
||||
whatsapp_intervalo_segundos.to_i.clamp(0, 300)
|
||||
end
|
||||
|
||||
# ── WhatsApp / Twilio ───────────────────────────────────────
|
||||
def whatsapp_pronto?
|
||||
whatsapp_ativo? && twilio_account_sid.present? &&
|
||||
@@ -153,9 +109,7 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
end
|
||||
|
||||
def whatsapp_habilitado?
|
||||
return false unless whatsapp_ativo?
|
||||
|
||||
baileys? ? baileys_pronto? : credenciais_whatsapp.present?
|
||||
whatsapp_ativo? && credenciais_whatsapp.present?
|
||||
end
|
||||
|
||||
# Tudo-ou-nada: nunca mistura o SID do banco com o token do .env (a conta
|
||||
@@ -208,22 +162,12 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
self.remetente_nome = remetente_nome.to_s.strip.presence
|
||||
self.email_admin = email_admin.to_s.strip.downcase.presence
|
||||
self.twilio_account_sid = twilio_account_sid.to_s.strip.presence
|
||||
self.baileys_url = baileys_url.to_s.strip.chomp('/').presence
|
||||
self.whatsapp_provedor = whatsapp_provedor.presence || 'baileys'
|
||||
# Mantém o que o admin digitou se não der para normalizar — a validação de
|
||||
# presença acusa, e ele vê o próprio valor de volta no campo para corrigir.
|
||||
self.twilio_from = self.class.normalizar_telefone(twilio_from) || twilio_from.presence
|
||||
self.whatsapp_admin = self.class.normalizar_telefone(whatsapp_admin) || whatsapp_admin.presence
|
||||
end
|
||||
|
||||
def baileys_configuravel
|
||||
return if baileys_token_efetivo.present?
|
||||
|
||||
errors.add(:baileys_token,
|
||||
'é obrigatório: defina WHATSAPP_TOKEN no .env (o mesmo do container `whatsapp`) ' \
|
||||
'ou preencha aqui.')
|
||||
end
|
||||
|
||||
def senha_smtp_presente
|
||||
return if smtp_password.present?
|
||||
|
||||
|
||||
@@ -33,18 +33,8 @@ class Consolidacao < ApplicationRecord
|
||||
|
||||
# ── 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/08–26/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/08–26/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, -> {
|
||||
@@ -387,19 +377,12 @@ class Consolidacao < ApplicationRecord
|
||||
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
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# app/models/contato.rb
|
||||
#
|
||||
# Destinatário das notificações, cadastrado à mão pelo ADM: nome, número e/ou
|
||||
# e-mail, e o grupo a que pertence.
|
||||
#
|
||||
# Telefone é gravado SEMPRE em E.164 (+5511920051157). O ADM digita como quiser
|
||||
# — "(11) 92005-1157", "011 92005 1157" — e a normalização acontece aqui, uma
|
||||
# vez, na gravação. Mandar o texto cru para o provedor devolve erro de destino
|
||||
# inválido que o rescue do envio esconderia.
|
||||
class Contato < ApplicationRecord
|
||||
belongs_to :grupo_contato, optional: true
|
||||
belongs_to :user, optional: true
|
||||
has_many :notificacao_envios, dependent: :nullify
|
||||
|
||||
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
|
||||
|
||||
# Um destinatário é uma PESSOA (telefone e/ou e-mail) ou um GRUPO do WhatsApp.
|
||||
# O grupo entra aqui, e não em tabela própria, para herdar grupo interno,
|
||||
# assinatura de eventos e log de envios sem duplicar essa máquina.
|
||||
TIPOS = %w[pessoa grupo_whatsapp].freeze
|
||||
|
||||
# Identificador de grupo do WhatsApp: "120363012345678901@g.us".
|
||||
JID_GRUPO_RE = /\A\d{5,}@g\.us\z/
|
||||
|
||||
validates :nome, presence: true
|
||||
validates :tipo, inclusion: { in: TIPOS }
|
||||
validates :email, format: { with: EMAIL_RE }, allow_blank: true
|
||||
validate :telefone_ou_email, if: :pessoa?
|
||||
validate :telefone_valido, if: :pessoa?
|
||||
validate :grupo_identificado, if: :grupo_whatsapp?
|
||||
|
||||
before_validation :normalizar
|
||||
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||
scope :pessoas, -> { where(tipo: 'pessoa') }
|
||||
scope :grupos_whatsapp, -> { where(tipo: 'grupo_whatsapp') }
|
||||
# "Tem WhatsApp" = número de pessoa OU identificador de grupo.
|
||||
scope :com_whatsapp, -> {
|
||||
where.not(telefone: [nil, '']).or(where.not(whatsapp_grupo_jid: [nil, '']))
|
||||
}
|
||||
scope :com_email, -> { where.not(email: [nil, '']) }
|
||||
|
||||
# Filtra pelo canal do disparo. 'ambos' = quem tiver qualquer um dos dois.
|
||||
scope :com_canal, ->(canal) {
|
||||
case canal.to_s
|
||||
when 'whatsapp' then com_whatsapp
|
||||
when 'email' then com_email
|
||||
else where("(telefone IS NOT NULL AND telefone <> '') OR (email IS NOT NULL AND email <> '')")
|
||||
end
|
||||
}
|
||||
|
||||
def pessoa? = tipo.to_s != 'grupo_whatsapp'
|
||||
def grupo_whatsapp? = tipo.to_s == 'grupo_whatsapp'
|
||||
|
||||
def whatsapp? = destino_whatsapp.present?
|
||||
def email? = email.present? && pessoa?
|
||||
|
||||
# Para onde o WhatsApp vai: o JID do grupo ou o número da pessoa. É o que a
|
||||
# ponte recebe — ela aceita as duas formas.
|
||||
def destino_whatsapp
|
||||
grupo_whatsapp? ? whatsapp_grupo_jid.presence : telefone.presence
|
||||
end
|
||||
|
||||
# {{contato}} na mensagem. Em grupo não existe "primeiro nome" — cortar
|
||||
# "Motoristas SP" em "Motoristas" só empobreceria o texto.
|
||||
def primeiro_nome
|
||||
grupo_whatsapp? ? nome.to_s : nome.to_s.split.first.to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalizar
|
||||
self.nome = nome.to_s.strip.squeeze(' ').presence
|
||||
self.tipo = tipo.presence || 'pessoa'
|
||||
|
||||
if grupo_whatsapp?
|
||||
# Grupo não tem telefone nem e-mail: limpar evita um cadastro meio pessoa,
|
||||
# meio grupo, que mandaria a mesma mensagem duas vezes.
|
||||
self.telefone = nil
|
||||
self.email = nil
|
||||
self.whatsapp_grupo_jid = whatsapp_grupo_jid.to_s.strip.presence
|
||||
self.whatsapp_grupo_nome = whatsapp_grupo_nome.to_s.strip.presence
|
||||
else
|
||||
self.whatsapp_grupo_jid = nil
|
||||
self.whatsapp_grupo_nome = nil
|
||||
self.email = email.to_s.strip.downcase.presence
|
||||
# Mantém o que foi digitado se não der para normalizar — a validação acusa
|
||||
# e o ADM vê o próprio valor de volta no campo para corrigir.
|
||||
bruto = telefone.to_s.strip.presence
|
||||
self.telefone = bruto && (ConfiguracaoNotificacao.normalizar_telefone(bruto) || bruto)
|
||||
end
|
||||
end
|
||||
|
||||
def grupo_identificado
|
||||
return if whatsapp_grupo_jid.to_s.match?(JID_GRUPO_RE)
|
||||
|
||||
errors.add(:whatsapp_grupo_jid,
|
||||
'inválido: escolha um grupo da lista (o identificador termina em @g.us)')
|
||||
end
|
||||
|
||||
def telefone_ou_email
|
||||
return if telefone.present? || email.present?
|
||||
|
||||
errors.add(:base, 'Informe ao menos um WhatsApp ou um e-mail.')
|
||||
end
|
||||
|
||||
def telefone_valido
|
||||
return if telefone.blank?
|
||||
return if ConfiguracaoNotificacao.normalizar_telefone(telefone).present?
|
||||
|
||||
errors.add(:telefone, 'não parece um número brasileiro válido (DDD + número)')
|
||||
end
|
||||
end
|
||||
@@ -92,39 +92,19 @@ class Entrega < ApplicationRecord
|
||||
# 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)
|
||||
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 ────────────────────────────────────────
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# app/models/evento_notificacao.rb
|
||||
#
|
||||
# Um evento que dispara mensagem. O ADM cadastra quantos quiser e liga cada um a
|
||||
# grupos de contato — mas o GATILHO sai de GATILHOS, porque gatilho é código.
|
||||
#
|
||||
# manual → botão "disparar agora" (aviso pontual)
|
||||
# consolidacao_finalizada → NotificacaoService.notificar_finalizacao
|
||||
# pagamento_efetuado → NotificacaoService.notificar_pagamento
|
||||
# valor_alterado → edição de lançamento / desconto após o fechamento
|
||||
# operacao_alterada → NF que entrou/saiu, entrega que mudou de status
|
||||
# agendado → resumo periódico (frequencia + hora)
|
||||
#
|
||||
# `sistema: true` marca os que nascem com o app: editáveis e desligáveis, mas
|
||||
# não apagáveis — apagar deixaria o código disparando no vazio.
|
||||
class EventoNotificacao < ApplicationRecord
|
||||
self.table_name = 'eventos_notificacao'
|
||||
|
||||
GATILHOS = %w[manual consolidacao_finalizada pagamento_efetuado
|
||||
valor_alterado operacao_alterada agendado].freeze
|
||||
|
||||
GATILHO_LABEL = {
|
||||
'manual' => 'Disparo manual (botão)',
|
||||
'consolidacao_finalizada' => 'Consolidação finalizada',
|
||||
'pagamento_efetuado' => 'Pagamento efetuado',
|
||||
'valor_alterado' => 'Valor alterado no fechamento',
|
||||
'operacao_alterada' => 'Dados da operação mudaram',
|
||||
'agendado' => 'Agendado (resumo periódico)'
|
||||
}.freeze
|
||||
|
||||
FREQUENCIAS = %w[diaria semanal].freeze
|
||||
|
||||
# Gatilhos que se referem a UMA pessoa específica (o motorista do pagamento),
|
||||
# e não só a grupos. Só neles `notificar_envolvido` faz sentido.
|
||||
GATILHOS_COM_ENVOLVIDO = %w[consolidacao_finalizada pagamento_efetuado valor_alterado].freeze
|
||||
|
||||
has_many :assinaturas, class_name: 'GrupoEventoAssinatura', dependent: :destroy
|
||||
has_many :grupos_contato, through: :assinaturas, source: :grupo_contato
|
||||
has_many :notificacao_envios, dependent: :nullify
|
||||
has_many :mensagem_templates, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :assinaturas, allow_destroy: true
|
||||
|
||||
validates :nome, presence: true
|
||||
validates :chave, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :gatilho, inclusion: { in: GATILHOS }
|
||||
validates :frequencia, inclusion: { in: FREQUENCIAS }, if: :agendado?
|
||||
validates :hora, numericality: { only_integer: true, greater_than_or_equal_to: 0,
|
||||
less_than_or_equal_to: 23 }, allow_nil: true
|
||||
validates :dia_semana, numericality: { only_integer: true, greater_than_or_equal_to: 0,
|
||||
less_than_or_equal_to: 6 }, allow_nil: true
|
||||
validate :semanal_tem_dia
|
||||
|
||||
before_validation :normalizar
|
||||
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||
scope :do_gatilho, ->(g) { ativos.where(gatilho: g) }
|
||||
|
||||
def agendado? = gatilho == 'agendado'
|
||||
def manual? = gatilho == 'manual'
|
||||
def envolvido_aplicavel? = GATILHOS_COM_ENVOLVIDO.include?(gatilho)
|
||||
def gatilho_label = GATILHO_LABEL[gatilho] || gatilho
|
||||
|
||||
# Só pode apagar o que o ADM criou.
|
||||
def apagavel? = !sistema?
|
||||
|
||||
# Template do canal, criando um vazio em memória quando ainda não existe —
|
||||
# assim a tela do editor abre igual para evento novo e evento já configurado.
|
||||
def template(canal)
|
||||
mensagem_templates.find { |t| t.canal == canal.to_s } ||
|
||||
mensagem_templates.build(canal: canal.to_s, blocos: [])
|
||||
end
|
||||
|
||||
# O corpo configurado no editor vence o texto padrão do código. Um template
|
||||
# ativo porém VAZIO não conta (mandaria mensagem em branco).
|
||||
def template_utilizavel(canal)
|
||||
t = mensagem_templates.detect { |x| x.canal == canal.to_s }
|
||||
t&.utilizavel? ? t : nil
|
||||
end
|
||||
|
||||
# Todas as variáveis do sistema, agrupadas por origem e já marcadas com
|
||||
# `aplicavel:` para este gatilho — a lista curta de antes obrigava deploy para
|
||||
# o ADM escrever qualquer número no texto.
|
||||
def variaveis = Notificacao::CatalogoVariaveis.por_origem(gatilho)
|
||||
|
||||
# Só os nomes que ESTE gatilho preenche (usado pela invariante dos specs).
|
||||
def variaveis_do_contexto = Notificacao::Variaveis.nomes(gatilho)
|
||||
|
||||
# Contatos que devem receber este evento, sem repetição — a mesma pessoa pode
|
||||
# estar em dois grupos assinantes.
|
||||
def destinatarios(canal)
|
||||
ids = assinaturas.where(ativo: true)
|
||||
.select { |a| a.cobre_canal?(canal) }
|
||||
.map(&:grupo_contato_id)
|
||||
return Contato.none if ids.empty?
|
||||
|
||||
Contato.ativos.where(grupo_contato_id: ids).com_canal(canal).distinct
|
||||
end
|
||||
|
||||
# Já passou da hora de disparar hoje/esta semana?
|
||||
def vencido?(agora = Time.current)
|
||||
return false unless agendado? && ativo?
|
||||
return false unless agora.hour >= hora.to_i
|
||||
return false if frequencia == 'semanal' && agora.wday != dia_semana.to_i
|
||||
return true if ultimo_disparo_em.blank?
|
||||
|
||||
ultimo_disparo_em < agora.beginning_of_day
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalizar
|
||||
self.nome = nome.to_s.strip.squeeze(' ').presence
|
||||
self.chave = chave.presence || nome.to_s.parameterize(separator: '_').presence
|
||||
self.chave = chave.to_s.strip.downcase.presence
|
||||
self.dia_semana = nil unless agendado? && frequencia == 'semanal'
|
||||
self.frequencia = nil unless agendado?
|
||||
end
|
||||
|
||||
def semanal_tem_dia
|
||||
return unless agendado? && frequencia == 'semanal' && dia_semana.blank?
|
||||
|
||||
errors.add(:dia_semana, 'é obrigatório na frequência semanal')
|
||||
end
|
||||
end
|
||||
@@ -1,38 +0,0 @@
|
||||
# app/models/grupo_contato.rb
|
||||
#
|
||||
# Grupo de destinatários (Diretoria, Operação, Motoristas, Cliente...). É o
|
||||
# GRUPO que assina os eventos: cadastrar alguém novo é escolher o grupo, não
|
||||
# repetir a configuração pessoa por pessoa.
|
||||
class GrupoContato < ApplicationRecord
|
||||
self.table_name = 'grupos_contato'
|
||||
|
||||
has_many :contatos, dependent: :nullify
|
||||
has_many :assinaturas, class_name: 'GrupoEventoAssinatura', dependent: :destroy
|
||||
has_many :eventos, through: :assinaturas, source: :evento_notificacao
|
||||
|
||||
validates :nome, presence: true
|
||||
validate :nome_unico
|
||||
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||
|
||||
before_validation { self.nome = nome.to_s.strip.squeeze(' ').presence }
|
||||
|
||||
# Contatos que devem receber ESTE evento por ESTE canal.
|
||||
def destinatarios(canal)
|
||||
return GrupoContato.none unless ativo?
|
||||
|
||||
contatos.ativos.com_canal(canal)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Índice único é sobre LOWER(nome); validar aqui dá a mensagem amigável em vez
|
||||
# de estourar RecordNotUnique na tela.
|
||||
def nome_unico
|
||||
return if nome.blank?
|
||||
|
||||
existe = GrupoContato.where.not(id: id).where('LOWER(nome) = ?', nome.downcase).exists?
|
||||
errors.add(:nome, 'já existe') if existe
|
||||
end
|
||||
end
|
||||
@@ -1,22 +0,0 @@
|
||||
# app/models/grupo_evento_assinatura.rb
|
||||
#
|
||||
# "O grupo X recebe o evento Y por WhatsApp/e-mail/ambos."
|
||||
class GrupoEventoAssinatura < ApplicationRecord
|
||||
CANAIS = %w[ambos whatsapp email].freeze
|
||||
CANAL_LABEL = { 'ambos' => 'WhatsApp e e-mail', 'whatsapp' => 'Só WhatsApp', 'email' => 'Só e-mail' }.freeze
|
||||
|
||||
belongs_to :grupo_contato
|
||||
belongs_to :evento_notificacao
|
||||
|
||||
validates :canal, inclusion: { in: CANAIS }
|
||||
validates :grupo_contato_id, uniqueness: { scope: :evento_notificacao_id,
|
||||
message: 'já assina este evento' }
|
||||
|
||||
scope :ativas, -> { where(ativo: true) }
|
||||
|
||||
def cobre_canal?(canal)
|
||||
self.canal == 'ambos' || self.canal == canal.to_s
|
||||
end
|
||||
|
||||
def canal_label = CANAL_LABEL[canal] || canal
|
||||
end
|
||||
@@ -1,85 +0,0 @@
|
||||
# app/models/mensagem_template.rb
|
||||
#
|
||||
# Corpo de um evento em UM canal, montado com blocos no editor.
|
||||
#
|
||||
# `blocos` vem de um campo hidden preenchido por JavaScript — ou seja, de fora.
|
||||
# Por isso #normalizar_blocos DESCARTA o que não está no catálogo: tipo
|
||||
# desconhecido, campo que aquele tipo não tem, e qualquer coisa que não seja
|
||||
# Hash. Sem isso, um JSON adulterado no form viraria conteúdo renderizado sem
|
||||
# passar por validação nenhuma.
|
||||
class MensagemTemplate < ApplicationRecord
|
||||
CANAIS = %w[whatsapp email].freeze
|
||||
# Teto de segurança: um template com milhares de blocos só serviria para
|
||||
# travar o render do e-mail.
|
||||
MAX_BLOCOS = 40
|
||||
MAX_LINHAS_TABELA = 20
|
||||
|
||||
belongs_to :evento_notificacao
|
||||
|
||||
validates :canal, inclusion: { in: CANAIS }
|
||||
validates :canal, uniqueness: { scope: :evento_notificacao_id }
|
||||
validate :assunto_no_email
|
||||
|
||||
before_validation :normalizar_blocos
|
||||
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :do_canal, ->(canal) { where(canal: canal.to_s) }
|
||||
|
||||
def email? = canal == 'email'
|
||||
def whatsapp? = canal == 'whatsapp'
|
||||
|
||||
# Um template só entra em uso quando tem conteúdo — template ativo e vazio
|
||||
# mandaria mensagem em branco, o que é pior do que cair no texto padrão.
|
||||
def utilizavel?
|
||||
ativo? && blocos.present?
|
||||
end
|
||||
|
||||
def renderizador(dados = {})
|
||||
Notificacao::Renderizador.new(blocos, dados)
|
||||
end
|
||||
|
||||
def preview(dados = nil)
|
||||
dados ||= Notificacao::CatalogoVariaveis.amostra(evento_notificacao&.gatilho)
|
||||
r = renderizador(dados)
|
||||
email? ? r.html : r.texto
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalizar_blocos
|
||||
lista = blocos.is_a?(Array) ? blocos : []
|
||||
self.blocos = lista.filter_map { |bruto| limpar(bruto) }.first(MAX_BLOCOS)
|
||||
end
|
||||
|
||||
def limpar(bruto)
|
||||
return nil unless bruto.is_a?(Hash)
|
||||
|
||||
tipo = bruto['tipo'] || bruto[:tipo]
|
||||
return nil unless Notificacao::Blocos.valido?(tipo)
|
||||
|
||||
limpo = { 'tipo' => tipo.to_s }
|
||||
Notificacao::Blocos.campos(tipo).each do |campo, especie|
|
||||
valor = bruto[campo] || bruto[campo.to_sym]
|
||||
limpo[campo] = especie == :lista ? limpar_linhas(valor) : valor.to_s
|
||||
end
|
||||
limpo
|
||||
end
|
||||
|
||||
def limpar_linhas(valor)
|
||||
Array(valor).filter_map do |linha|
|
||||
next unless linha.is_a?(Hash)
|
||||
|
||||
rotulo = (linha['rotulo'] || linha[:rotulo]).to_s
|
||||
conteudo = (linha['valor'] || linha[:valor]).to_s
|
||||
next if rotulo.blank? && conteudo.blank?
|
||||
|
||||
{ 'rotulo' => rotulo, 'valor' => conteudo }
|
||||
end.first(MAX_LINHAS_TABELA)
|
||||
end
|
||||
|
||||
def assunto_no_email
|
||||
return unless email? && ativo? && blocos.present? && assunto.to_s.strip.blank?
|
||||
|
||||
errors.add(:assunto, 'é obrigatório no e-mail')
|
||||
end
|
||||
end
|
||||
@@ -1,41 +0,0 @@
|
||||
# app/models/notificacao_envio.rb
|
||||
#
|
||||
# Registro de UMA mensagem disparada. Existe para responder "o motorista
|
||||
# recebeu?" — pergunta que hoje só tem resposta no log do Rails, porque o
|
||||
# NotificacaoService engole a exceção de propósito (um SMTP fora do ar não pode
|
||||
# travar o fechamento de um pagamento).
|
||||
#
|
||||
# Com o WhatsApp por sessão QR, que cai sozinha e exige repareamento, essa
|
||||
# pergunta vira rotina — daí o log ser tabela, não linha de arquivo.
|
||||
class NotificacaoEnvio < ApplicationRecord
|
||||
STATUS = %w[pendente enviado falhou].freeze
|
||||
CANAIS = %w[whatsapp email].freeze
|
||||
|
||||
belongs_to :evento_notificacao, optional: true
|
||||
belongs_to :contato, optional: true
|
||||
belongs_to :user, optional: true
|
||||
|
||||
validates :canal, inclusion: { in: CANAIS }
|
||||
validates :status, inclusion: { in: STATUS }
|
||||
validates :destino, presence: true
|
||||
|
||||
scope :recentes, -> { order(created_at: :desc) }
|
||||
scope :falhados, -> { where(status: 'falhou') }
|
||||
scope :enviados, -> { where(status: 'enviado') }
|
||||
scope :no_periodo, ->(inicio, fim) {
|
||||
where(created_at: inicio.to_date.beginning_of_day..fim.to_date.end_of_day)
|
||||
}
|
||||
|
||||
def marcar_enviado!
|
||||
update!(status: 'enviado', enviado_em: Time.current, erro: nil,
|
||||
tentativas: tentativas + 1)
|
||||
end
|
||||
|
||||
def marcar_falha!(erro)
|
||||
update!(status: 'falhou', erro: erro.to_s.truncate(2000),
|
||||
tentativas: tentativas + 1)
|
||||
end
|
||||
|
||||
def enviado? = status == 'enviado'
|
||||
def falhou? = status == 'falhou'
|
||||
end
|
||||
@@ -78,31 +78,6 @@ class Operacao
|
||||
grupos.sort_by { |titulo, _| ordem[titulo] }.map { |titulo, itens| [titulo, itens] }
|
||||
end
|
||||
|
||||
# Em QUAIS operações cada NF aparece: { '85382' => ['UBS NORTE AGO 2026'] }.
|
||||
# NFs que não estiverem em nenhuma planilha simplesmente não vêm no Hash — é
|
||||
# assim que a tela de pendentes marca "fora da operação" (plano avulso/de
|
||||
# inclusão) sem precisar de uma query por tabela.
|
||||
#
|
||||
# SEGURANÇA: as tabelas vêm de #nomes_validos (catálogo) + quote_table_name;
|
||||
# as NFs entram por conn.quote. Uma query só, com UNION ALL.
|
||||
def por_notas(notas)
|
||||
lista = Array(notas).map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
||||
return {} if lista.empty?
|
||||
|
||||
tabelas = nomes_validos
|
||||
return {} if tabelas.empty?
|
||||
|
||||
valores = lista.map { |n| conn.quote(n) }.join(', ')
|
||||
sql = tabelas.map do |t|
|
||||
"SELECT nota_fiscal, #{conn.quote(label(t))} AS operacao " \
|
||||
"FROM #{conn.quote_table_name(t)} WHERE nota_fiscal IN (#{valores})"
|
||||
end.join(' UNION ALL ')
|
||||
|
||||
conn.select_all(sql).to_a
|
||||
.group_by { |r| r['nota_fiscal'].to_s }
|
||||
.transform_values { |rows| rows.map { |r| r['operacao'] }.uniq.sort }
|
||||
end
|
||||
|
||||
# Dados agregados das entregas das operações informadas, para pré-preencher o
|
||||
# formulário de nova consolidação. Une as NFs de todas as tabelas válidas.
|
||||
def dados(tabelas)
|
||||
@@ -121,14 +96,6 @@ class Operacao
|
||||
}
|
||||
end
|
||||
|
||||
# Número do mês a partir de um token do nome ("ago", "agosto", "AGOSTO").
|
||||
# Público de propósito: Romaneios::CasadorDeOperacao precisa da MESMA tabela de
|
||||
# meses para casar o nome do plano do SimpliRoute com a tabela da operação, e
|
||||
# duplicar esse mapa seria criar uma segunda verdade que sai de sincronia.
|
||||
def mes_numero(token)
|
||||
MESES[token.to_s.downcase]&.first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extrai { titulo:, item:, ordem: } do nome da tabela.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# app/models/operacao_snapshot.rb
|
||||
#
|
||||
# Últimos números conhecidos de uma operação. Comparar com os de agora é o que
|
||||
# permite dizer O QUE mudou — e não só como está.
|
||||
class OperacaoSnapshot < ApplicationRecord
|
||||
CONTADORES = %i[total_notas entregues nao_entregues pendentes].freeze
|
||||
|
||||
ROTULOS = {
|
||||
total_notas: 'notas na operação',
|
||||
entregues: 'entregues',
|
||||
nao_entregues: 'não entregues',
|
||||
pendentes: 'em aberto'
|
||||
}.freeze
|
||||
|
||||
validates :tabela, presence: true, uniqueness: true
|
||||
|
||||
def self.para(tabela) = find_or_initialize_by(tabela: tabela.to_s)
|
||||
|
||||
def numeros = CONTADORES.index_with { |c| send(c).to_i }
|
||||
|
||||
# Diferença legível entre o retrato guardado e os números de agora:
|
||||
# { total_notas: +3, pendentes: -1 } => "3 notas na operação a mais, 1 em aberto a menos"
|
||||
def diferenca(atuais)
|
||||
CONTADORES.filter_map do |contador|
|
||||
delta = atuais[contador].to_i - send(contador).to_i
|
||||
next if delta.zero?
|
||||
|
||||
[contador, delta]
|
||||
end.to_h
|
||||
end
|
||||
|
||||
def self.descrever(diferenca)
|
||||
diferenca.map do |contador, delta|
|
||||
"#{delta.abs} #{ROTULOS.fetch(contador, contador)} #{delta.positive? ? 'a mais' : 'a menos'}"
|
||||
end.join(', ')
|
||||
end
|
||||
|
||||
def registrar!(atuais)
|
||||
update!(atuais.merge(capturado_em: Time.current))
|
||||
end
|
||||
end
|
||||
@@ -1,62 +0,0 @@
|
||||
# app/models/perfil_acesso.rb
|
||||
#
|
||||
# O conjunto de permissões que o ADM monta na tela e atribui a um usuário.
|
||||
# O catálogo do que PODE ser marcado é código (Permissao::TODAS); aqui fica só a
|
||||
# escolha do ADM.
|
||||
class PerfilAcesso < ApplicationRecord
|
||||
# Nome da tabela fixado: o Inflector pt-BR resolveria `perfil_acesso` como
|
||||
# "perfil_acessos" (mesma pegadinha de ConsolidacaoMotorista e das tabelas de
|
||||
# notificação). A tabela real é `perfis_acesso`.
|
||||
self.table_name = 'perfis_acesso'
|
||||
|
||||
has_many :users, foreign_key: :perfil_acesso_id, dependent: :nullify, inverse_of: :perfil_acesso
|
||||
|
||||
validates :nome, presence: true
|
||||
validates :nome, uniqueness: { case_sensitive: false }
|
||||
|
||||
before_validation :normalizar_permissoes
|
||||
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||
scope :de_sistema, -> { where(sistema: true) }
|
||||
|
||||
# Chaves que ainda existem no catálogo. Uma permissão removida do código não
|
||||
# pode travar um perfil salvo meses atrás — ela simplesmente deixa de valer.
|
||||
def permissoes_validas
|
||||
Permissao.filtrar(permissoes)
|
||||
end
|
||||
|
||||
def pode?(chave)
|
||||
permissoes_validas.include?(chave.to_s)
|
||||
end
|
||||
|
||||
# Perfil de sistema é editável e desativável, mas não apagável (apagar deixaria
|
||||
# usuários órfãos e a tela sem ponto de partida). Perfil em uso também não sai:
|
||||
# o usuário cairia no fallback do papel sem ninguém perceber.
|
||||
def apagavel?
|
||||
!sistema? && users.none?
|
||||
end
|
||||
|
||||
# Este perfil ainda consegue reabrir a porta do sistema? É o que a trava do
|
||||
# último administrador pergunta antes de deixar salvar.
|
||||
def administra_acesso?
|
||||
ativo? && pode?('admin.perfis') && pode?('admin.usuarios_gerenciar')
|
||||
end
|
||||
|
||||
def total_permissoes
|
||||
permissoes_validas.size
|
||||
end
|
||||
|
||||
# Um perfil que não enxerga nada existe (é uma forma de suspender acesso sem
|
||||
# desativar a conta), mas a tela precisa avisar.
|
||||
def vazio?
|
||||
permissoes_validas.empty?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalizar_permissoes
|
||||
self.nome = nome.to_s.strip
|
||||
self.permissoes = Permissao.filtrar(permissoes)
|
||||
end
|
||||
end
|
||||
@@ -1,251 +0,0 @@
|
||||
# app/models/permissao.rb
|
||||
#
|
||||
# Catálogo das permissões do sistema — fonte ÚNICA de "o que dá para liberar".
|
||||
# Não é tabela: permissão é código (cada chave corresponde a um `authorize` real),
|
||||
# então versioná-la no banco só criaria duas verdades. O que o ADM cadastra é o
|
||||
# PERFIL (PerfilAcesso), que guarda uma lista destas chaves.
|
||||
#
|
||||
# REGRA DE GRANULARIDADE: uma chave por TELA (`ver`) e uma chave a mais só quando
|
||||
# a ação tem régua diferente do "ver" — foi assim que o sistema já se comportava
|
||||
# (ex.: ver consolidação x registrar pagamento). Quebrar mais fino do que isso
|
||||
# encheria a tela de perfil de caixinhas que ninguém sabe responder.
|
||||
#
|
||||
# ⚠️ Toda chave nova precisa (1) entrar aqui, (2) ser usada por alguma policy ou
|
||||
# view e (3) entrar em PADRAO_POR_ROLE se algum papel legado já tinha aquele
|
||||
# acesso — senão o fallback silenciosamente tira acesso de quem tinha.
|
||||
module Permissao
|
||||
# Ordem em que os grupos aparecem na tela de perfil.
|
||||
GRUPOS = {
|
||||
'dashboard' => 'Dashboard e operações',
|
||||
'consolidacao' => 'Consolidações e pagamento',
|
||||
'notificacao' => 'Notificações',
|
||||
'admin' => 'Administração'
|
||||
}.freeze
|
||||
|
||||
# chave => { grupo, label (o que o ADM lê), descricao (o efeito real) }
|
||||
TODAS = {
|
||||
# ── Dashboard ────────────────────────────────────────────
|
||||
'dashboard.ver' => {
|
||||
grupo: 'dashboard', label: 'Ver o dashboard',
|
||||
descricao: 'Tela inicial com os números do período.'
|
||||
},
|
||||
'dashboard.financeiro' => {
|
||||
grupo: 'dashboard', label: 'Ver custos e pagamentos',
|
||||
descricao: 'Bloco financeiro, relatório em PDF e a lista de entregas em aberto.'
|
||||
},
|
||||
'dashboard.operacoes' => {
|
||||
grupo: 'dashboard', label: 'Ver o dashboard de operações',
|
||||
descricao: 'Entregas por operação, insucessos, mapa e planilha espelho.'
|
||||
},
|
||||
'dashboard.exportar' => {
|
||||
grupo: 'dashboard', label: 'Baixar a planilha da operação',
|
||||
descricao: 'Download do .xlsx que é entregue ao cliente — é dado saindo do sistema.'
|
||||
},
|
||||
'dashboard.metricas' => {
|
||||
grupo: 'dashboard', label: 'Consultar as métricas pela API',
|
||||
descricao: 'Endpoint interno /api/v1/dashboard/metricas.'
|
||||
},
|
||||
|
||||
# ── Consolidações ────────────────────────────────────────
|
||||
'consolidacao.ver' => {
|
||||
grupo: 'consolidacao', label: 'Ver consolidações',
|
||||
descricao: 'Lista, arquivadas, detalhe e prévia do extrato.'
|
||||
},
|
||||
'consolidacao.criar' => {
|
||||
grupo: 'consolidacao', label: 'Criar consolidação',
|
||||
descricao: 'Nova consolidação e nota avulsa.'
|
||||
},
|
||||
'consolidacao.editar' => {
|
||||
grupo: 'consolidacao', label: 'Classificar e lançar',
|
||||
descricao: 'Wizard, classificação das entregas, apontamentos e veículos.'
|
||||
},
|
||||
'consolidacao.editar_finalizada' => {
|
||||
grupo: 'consolidacao', label: 'Mexer depois de finalizada',
|
||||
descricao: 'Alterar uma consolidação já fechada — muda valor que o motorista vai receber.'
|
||||
},
|
||||
'consolidacao.finalizar' => {
|
||||
grupo: 'consolidacao', label: 'Finalizar consolidação',
|
||||
descricao: 'Fechar o período (inclusive o fechamento parcial).'
|
||||
},
|
||||
'consolidacao.arquivar' => {
|
||||
grupo: 'consolidacao', label: 'Arquivar e reativar',
|
||||
descricao: 'Tirar da lista sem apagar, e restaurar.'
|
||||
},
|
||||
'consolidacao.excluir' => {
|
||||
grupo: 'consolidacao', label: 'Excluir de vez',
|
||||
descricao: 'Apagar consolidação ou remover um motorista definitivamente.'
|
||||
},
|
||||
'consolidacao.registrar_pagamento' => {
|
||||
grupo: 'consolidacao', label: 'Registrar pagamento',
|
||||
descricao: 'Marcar motorista como pago (forma de pagamento e nota fiscal).'
|
||||
},
|
||||
'consolidacao.cancelar_pagamento' => {
|
||||
grupo: 'consolidacao', label: 'Estornar pagamento',
|
||||
descricao: 'Desfazer um pagamento já registrado.'
|
||||
},
|
||||
'consolidacao.gerir_motoristas' => {
|
||||
grupo: 'consolidacao', label: 'Adicionar e arquivar motorista',
|
||||
descricao: 'Quem entra e quem sai do fechamento (sem excluir).'
|
||||
},
|
||||
'consolidacao.exportar_pdf' => {
|
||||
grupo: 'consolidacao', label: 'Baixar relatórios em PDF',
|
||||
descricao: 'Extrato do motorista, relatório individual, financeiro e totais.'
|
||||
},
|
||||
|
||||
# ── Notificações ─────────────────────────────────────────
|
||||
'notificacao.contatos' => {
|
||||
grupo: 'notificacao', label: 'Ver contatos e grupos',
|
||||
descricao: 'Quem recebe as mensagens.'
|
||||
},
|
||||
'notificacao.contatos_gerenciar' => {
|
||||
grupo: 'notificacao', label: 'Criar e editar contatos/grupos',
|
||||
descricao: 'Cadastro de destinatários e de grupos de WhatsApp.'
|
||||
},
|
||||
'notificacao.contatos_excluir' => {
|
||||
grupo: 'notificacao', label: 'Excluir contatos/grupos',
|
||||
descricao: 'Remoção definitiva de destinatário.'
|
||||
},
|
||||
'notificacao.eventos' => {
|
||||
grupo: 'notificacao', label: 'Ver eventos',
|
||||
descricao: 'Lista dos avisos configurados e seus gatilhos.'
|
||||
},
|
||||
'notificacao.eventos_gerenciar' => {
|
||||
grupo: 'notificacao', label: 'Criar e editar eventos',
|
||||
descricao: 'Gatilho, grupos assinantes e o editor de mensagem.'
|
||||
},
|
||||
'notificacao.disparar_manual' => {
|
||||
grupo: 'notificacao', label: 'Disparar mensagem agora',
|
||||
descricao: 'Botão que envia de verdade, em nome da empresa.'
|
||||
},
|
||||
'notificacao.variaveis' => {
|
||||
grupo: 'notificacao', label: 'Criar variáveis de mensagem',
|
||||
descricao: 'Cadastra {{variaveis}} próprias usadas nos textos de aviso.'
|
||||
},
|
||||
'notificacao.envios' => {
|
||||
grupo: 'notificacao', label: 'Ver o histórico de envios',
|
||||
descricao: 'O que saiu, para quem, e o que falhou.'
|
||||
},
|
||||
'notificacao.credenciais' => {
|
||||
grupo: 'notificacao', label: 'Configurar e-mail e WhatsApp',
|
||||
descricao: 'Credenciais de SMTP/Twilio e limites de envio.'
|
||||
},
|
||||
'notificacao.whatsapp_sessao' => {
|
||||
grupo: 'notificacao', label: 'Parear o WhatsApp',
|
||||
descricao: 'Ler o QR Code, testar e desconectar a sessão.'
|
||||
},
|
||||
|
||||
# ── Administração ────────────────────────────────────────
|
||||
'admin.usuarios' => {
|
||||
grupo: 'admin', label: 'Ver usuários',
|
||||
descricao: 'Lista de quem tem acesso ao sistema.'
|
||||
},
|
||||
'admin.usuarios_gerenciar' => {
|
||||
grupo: 'admin', label: 'Criar, editar e desativar usuários',
|
||||
descricao: 'Inclui definir o tipo de conta e o perfil de acesso de cada um.'
|
||||
},
|
||||
'admin.perfis' => {
|
||||
grupo: 'admin', label: 'Gerenciar perfis de acesso',
|
||||
descricao: 'Criar perfis e marcar o que cada um enxerga — esta tela.'
|
||||
},
|
||||
'admin.configuracoes' => {
|
||||
grupo: 'admin', label: 'Ver configurações',
|
||||
descricao: 'Tabela de preços e parâmetros do sistema.'
|
||||
},
|
||||
'admin.configuracoes_editar' => {
|
||||
grupo: 'admin', label: 'Alterar preços',
|
||||
descricao: 'Muda o valor de todo fechamento feito daqui para a frente.'
|
||||
},
|
||||
'admin.auditoria' => {
|
||||
grupo: 'admin', label: 'Ver a auditoria',
|
||||
descricao: 'Registro de quem fez o quê no sistema.'
|
||||
},
|
||||
'admin.edicao_lancamento' => {
|
||||
grupo: 'admin', label: 'Corrigir lançamento no SimpliRoute',
|
||||
descricao: 'Escreve na API do rastreio — altera o dado de origem.'
|
||||
},
|
||||
'admin.planilha_simpli_route' => {
|
||||
grupo: 'admin', label: 'Gerar planilha de carga',
|
||||
descricao: 'Download do .xlsx de importação do SimpliRoute.'
|
||||
},
|
||||
'admin.romaneio' => {
|
||||
grupo: 'admin', label: 'Gerar romaneio de entrega',
|
||||
descricao: 'Importa o plano do dia, edita a tabela e imprime o "CONTROLE DE ' \
|
||||
'ENTREGA" que o motorista assina ao receber a carga.'
|
||||
}
|
||||
}.freeze
|
||||
|
||||
# O que cada papel legado podia fazer ANTES dos perfis existirem. É o fallback
|
||||
# de quem ainda não tem perfil (usuário recém-criado, objeto não persistido nos
|
||||
# specs) e a receita dos perfis pré-prontos criados na migration de dados.
|
||||
#
|
||||
# ⚠️ Reproduz o comportamento atual de propósito — com duas exceções
|
||||
# deliberadas, que são justamente o que o ADM pediu para consertar:
|
||||
# • `externo` perde `dashboard.financeiro` (hoje enxerga custo e pagamento
|
||||
# porque o DashboardController não passa pelo Pundit);
|
||||
# • `operador` não recebe `admin.auditoria` (hoje a URL responde para ele por
|
||||
# causa do `policy_class: ApplicationPolicy`, mas o menu nunca ofereceu).
|
||||
PADRAO_POR_ROLE = {
|
||||
'admin' => :todas,
|
||||
'gerente' => %w[
|
||||
dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas
|
||||
consolidacao.ver consolidacao.criar consolidacao.editar consolidacao.editar_finalizada
|
||||
consolidacao.finalizar consolidacao.arquivar consolidacao.excluir
|
||||
consolidacao.registrar_pagamento consolidacao.cancelar_pagamento
|
||||
consolidacao.gerir_motoristas consolidacao.exportar_pdf
|
||||
notificacao.contatos notificacao.contatos_gerenciar notificacao.envios
|
||||
admin.usuarios admin.configuracoes admin.auditoria admin.planilha_simpli_route
|
||||
admin.romaneio
|
||||
].freeze,
|
||||
'operador' => %w[
|
||||
dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas
|
||||
consolidacao.ver consolidacao.criar consolidacao.editar consolidacao.finalizar
|
||||
consolidacao.gerir_motoristas consolidacao.exportar_pdf
|
||||
].freeze,
|
||||
'externo' => %w[dashboard.ver dashboard.operacoes].freeze,
|
||||
# Motorista não usa permissão: o que ele acessa é a área própria dele, que
|
||||
# continua governada pelo tipo de conta (Motorista::DashboardController).
|
||||
'motorista' => [].freeze
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def chaves
|
||||
@chaves ||= TODAS.keys.freeze
|
||||
end
|
||||
|
||||
def existe?(chave)
|
||||
TODAS.key?(chave.to_s)
|
||||
end
|
||||
|
||||
def label(chave)
|
||||
TODAS.dig(chave.to_s, :label) || chave.to_s
|
||||
end
|
||||
|
||||
def descricao(chave)
|
||||
TODAS.dig(chave.to_s, :descricao)
|
||||
end
|
||||
|
||||
def grupo_label(grupo)
|
||||
GRUPOS[grupo.to_s] || grupo.to_s
|
||||
end
|
||||
|
||||
# { 'dashboard' => [['dashboard.ver', {...}], ...], ... } na ordem de GRUPOS —
|
||||
# é como a tela de perfil desenha as seções.
|
||||
def por_grupo
|
||||
@por_grupo ||= GRUPOS.keys.index_with { |g| TODAS.select { |_, cfg| cfg[:grupo] == g }.to_a }.freeze
|
||||
end
|
||||
|
||||
# Chaves do papel legado. `:todas` (admin) vira a lista inteira.
|
||||
def do_role(role)
|
||||
padrao = PADRAO_POR_ROLE[role.to_s]
|
||||
return chaves if padrao == :todas
|
||||
|
||||
padrao || []
|
||||
end
|
||||
|
||||
# Descarta o que não existe no catálogo. Uma chave removida do código não pode
|
||||
# travar o cadastro de um perfil salvo meses atrás — ela simplesmente some.
|
||||
def filtrar(lista)
|
||||
Array(lista).map(&:to_s).uniq.select { |chave| existe?(chave) }
|
||||
end
|
||||
end
|
||||
@@ -1,88 +0,0 @@
|
||||
# app/models/romaneio.rb
|
||||
#
|
||||
# Um plano do SimpliRoute importado para virar o "CONTROLE DE ENTREGA" — a folha
|
||||
# que o motorista assina ao retirar a carga, uma página A4 paisagem por veículo.
|
||||
#
|
||||
# O romaneio é NOSSO (tabela gravável). As fontes continuam somente leitura: o
|
||||
# espelho de rastreio (Entrega) e as tabelas de operação (gade_entregas_*).
|
||||
class Romaneio < ApplicationRecord
|
||||
ORIGENS = %w[api espelho planilha].freeze
|
||||
|
||||
# Máximo de paradas que cabem numa folha. O número é da GEOMETRIA do PDF, então a
|
||||
# fonte é Pdf::RomaneioPdf — duplicar aqui criaria duas verdades, e o dia em que
|
||||
# alguém mexesse na margem a tela passaria a avisar "sai em 1 folha" para um
|
||||
# romaneio que sai em 2. Lido em tempo de chamada (e não numa constante) para não
|
||||
# amarrar a ordem de carga dos autoloads.
|
||||
def self.linhas_por_folha
|
||||
Pdf::RomaneioPdf::MAX_LINHAS
|
||||
end
|
||||
|
||||
belongs_to :importado_por, class_name: 'User', optional: true
|
||||
|
||||
has_many :romaneio_veiculos, -> { order(:posicao, :veiculo) },
|
||||
dependent: :destroy, inverse_of: :romaneio
|
||||
has_many :romaneio_linhas, dependent: :destroy, inverse_of: :romaneio
|
||||
|
||||
validates :planned_date, presence: true
|
||||
validates :origem, inclusion: { in: ORIGENS }
|
||||
validates :planned_date, uniqueness: { scope: :operacao_tabela }
|
||||
|
||||
scope :recentes, -> { order(planned_date: :desc, id: :desc) }
|
||||
|
||||
# Linhas que entram no PDF: as que continuam no plano, na ordem do roteiro.
|
||||
# `removido_em` marca a parada que saiu do plano mas tinha edição humana — ela
|
||||
# fica visível na tela para o operador decidir, e NUNCA sai impressa.
|
||||
def linhas_vigentes
|
||||
romaneio_linhas.where(removido_em: nil).order(:veiculo, :ordem, :id)
|
||||
end
|
||||
|
||||
def linhas_removidas
|
||||
romaneio_linhas.where.not(removido_em: nil).order(:veiculo, :ordem, :id)
|
||||
end
|
||||
|
||||
# { 'GADE_001' => 29, ... } — alimenta o seletor de veículo e o aviso de folhas.
|
||||
def contagem_por_veiculo
|
||||
@contagem_por_veiculo ||= linhas_vigentes.reorder(nil).group(:veiculo).count
|
||||
end
|
||||
|
||||
# { novos: 184, recorrentes: 1863 } — quantos pacientes recebem APARELHO.
|
||||
#
|
||||
# POR QUE APARECE NA TELA: é a conferência que o programa antigo fazia com uma
|
||||
# caixa de diálogo ("184 entrega(s) marcada(s) como NOVO") logo depois de
|
||||
# carregar o status. Sem esse número, um romaneio importado SEM operação
|
||||
# vinculada — em que ninguém é marcado como novo — parece idêntico a um
|
||||
# importado certo, e o erro só aparece no papel, com aparelho não entregue.
|
||||
# "0 novos" na tela é a pergunta que o operador precisa se fazer.
|
||||
def contagem_aparelho
|
||||
@contagem_aparelho ||= begin
|
||||
grupos = linhas_vigentes.reorder(nil).group(:aparelho).count
|
||||
total = grupos.values.sum
|
||||
novos = grupos.sum { |valor, qtd| valor.to_s.strip.casecmp('SIM').zero? ? qtd : 0 }
|
||||
{ novos: novos, recorrentes: total - novos }
|
||||
end
|
||||
end
|
||||
|
||||
def folhas_de(veiculo)
|
||||
total = contagem_por_veiculo[veiculo].to_i
|
||||
return 1 if total.zero?
|
||||
|
||||
(total.to_f / self.class.linhas_por_folha).ceil
|
||||
end
|
||||
|
||||
# Veículos que estouram uma folha — a tela avisa antes de imprimir, porque
|
||||
# descobrir isso só no papel é descobrir tarde.
|
||||
def veiculos_multifolha
|
||||
contagem_por_veiculo.select { |_v, n| n > self.class.linhas_por_folha }
|
||||
end
|
||||
|
||||
def operacao_label
|
||||
operacao_tabela.present? ? Operacao.label(operacao_tabela) : nil
|
||||
end
|
||||
|
||||
# Rótulo do canto direito do PDF ("EMAD 09.2026"). Só uma SUGESTÃO: não existe
|
||||
# fonte canônica para esse texto, então ele é editável na tela.
|
||||
def self.rotulo_sugerido(tabela, data)
|
||||
base = tabela.present? ? Operacao.label(tabela).split.first : nil
|
||||
[base, data.to_date.strftime('%m.%Y')].compact.join(' ')
|
||||
end
|
||||
end
|
||||
@@ -1,68 +0,0 @@
|
||||
# app/models/romaneio_linha.rb
|
||||
#
|
||||
# Uma PARADA do romaneio — vira uma linha da tabela do PDF.
|
||||
#
|
||||
# ⚠️ Nome de tabela fixado: "RomaneioLinha".tableize já dá `romaneio_linhas`, mas a
|
||||
# declaração explícita é a trava contra alguém acrescentar um irregular ao
|
||||
# config/initializers/inflections.rb e mudar isso sem perceber (ConsolidacaoMotorista
|
||||
# já quebrou /motorista em produção exatamente assim). Coberto por
|
||||
# spec/models/table_names_spec.rb.
|
||||
class RomaneioLinha < ApplicationRecord
|
||||
self.table_name = 'romaneio_linhas'
|
||||
|
||||
# Os únicos campos que o operador edita — e a whitelist que o controller usa
|
||||
# para não deixar um PATCH escrever em `veiculo`, `ordem` ou `chave_origem`.
|
||||
#
|
||||
# `veiculo` e `ordem` NÃO são editáveis de propósito: mover uma parada de veículo
|
||||
# aqui produziria um romaneio que contradiz a rota que o motorista recebe no
|
||||
# celular — dois papéis, mesma entrega, respostas diferentes. Veículo errado se
|
||||
# corrige no SimpliRoute e se reimporta.
|
||||
CAMPOS_EDITAVEIS = %w[nota_fiscal nome endereco aparelho telefones].freeze
|
||||
|
||||
belongs_to :romaneio, inverse_of: :romaneio_linhas
|
||||
|
||||
validates :veiculo, :chave_origem, presence: true
|
||||
|
||||
scope :vigentes, -> { where(removido_em: nil) }
|
||||
scope :removidas, -> { where.not(removido_em: nil) }
|
||||
scope :do_veiculo, ->(veiculo) { where(veiculo: veiculo) }
|
||||
|
||||
# Identidade ESTÁVEL entre reimportações. A NF quando existe; senão um digest do
|
||||
# título + endereço, porque nem toda linha do plano casa "NF 90507 - FULANO" e
|
||||
# duas linhas sem NF colidiriam no índice único, derrubando a importação inteira.
|
||||
#
|
||||
# Veículo e ordem ficam FORA da chave: mudam quando o plano é reotimizado e a
|
||||
# parada continua sendo a mesma entrega.
|
||||
def self.chave_para(nota_fiscal:, titulo: nil, endereco: nil)
|
||||
nf = nota_fiscal.to_s.strip
|
||||
return nf if nf.present?
|
||||
|
||||
"T:#{Digest::MD5.hexdigest("#{titulo}|#{endereco}")}"
|
||||
end
|
||||
|
||||
def editado?(campo)
|
||||
campos_editados.include?(campo.to_s)
|
||||
end
|
||||
|
||||
def editada?
|
||||
campos_editados.present?
|
||||
end
|
||||
|
||||
# Valor que o PLANO trouxe na última importação — é o que o botão "restaurar"
|
||||
# devolve, e o que a reconciliação compara.
|
||||
def valor_do_plano(campo)
|
||||
origem[campo.to_s].to_s
|
||||
end
|
||||
|
||||
# Marca o campo como tocado por humano. É ISTO que sobrevive à reimportação:
|
||||
# o dirty tracking é POR CAMPO, não por linha — "corrigi o telefone" não pode
|
||||
# significar "também respondo pelo endereço" e congelar um endereço velho quando
|
||||
# o SimpliRoute regeocodificar.
|
||||
def marcar_editado!(campo)
|
||||
self.campos_editados = (campos_editados + [campo.to_s]).uniq
|
||||
end
|
||||
|
||||
def desmarcar_editado!(campo)
|
||||
self.campos_editados = campos_editados - [campo.to_s]
|
||||
end
|
||||
end
|
||||
@@ -1,17 +0,0 @@
|
||||
# app/models/romaneio_veiculo.rb
|
||||
#
|
||||
# Uma FOLHA do romaneio: um veículo, uma página (ou mais, quando passa de
|
||||
# Romaneio::LINHAS_POR_FOLHA paradas).
|
||||
#
|
||||
# ⚠️ `motorista` NÃO é impresso. O rodapé do documento traz "MOTORISTA: ______"
|
||||
# para preenchimento à mão — é assim que a folha é assinada hoje, e o programa
|
||||
# Python que originou esta tela também coletava o nome sem usá-lo. O campo existe
|
||||
# para o operador saber, na tela, de quem é cada carro enquanto revisa.
|
||||
class RomaneioVeiculo < ApplicationRecord
|
||||
belongs_to :romaneio, inverse_of: :romaneio_veiculos
|
||||
|
||||
validates :veiculo, presence: true
|
||||
validates :veiculo, uniqueness: { scope: :romaneio_id }
|
||||
|
||||
scope :ordenados, -> { order(:posicao, :veiculo) }
|
||||
end
|
||||
@@ -6,10 +6,6 @@ class User < ApplicationRecord
|
||||
:rememberable,
|
||||
:validatable
|
||||
|
||||
# Perfil de acesso: nulo é válido (motorista não usa; e quem ainda não foi
|
||||
# migrado cai no padrão do papel — ver #permissoes_efetivas).
|
||||
belongs_to :perfil_acesso, optional: true, inverse_of: :users
|
||||
|
||||
# ── Roles ──────────────────────────────────────────────────
|
||||
# externo: acesso somente-leitura ao dashboard (ex.: gerências de outras áreas
|
||||
# que só precisam visualizar os indicadores). Login normal por e-mail/senha.
|
||||
@@ -61,113 +57,12 @@ class User < ApplicationRecord
|
||||
ROLES_LABEL[role] || role.humanize
|
||||
end
|
||||
|
||||
# Conta desativada não entra — nem por e-mail/senha.
|
||||
#
|
||||
# Só o login por PIN checava `ativo?` (Users::SessionsController), então
|
||||
# desativar um gerente não impedia nada, apesar do formulário prometer
|
||||
# "usuários inativos não conseguem fazer login". Devise chama este método em
|
||||
# todo login e em toda request de sessão existente, então desativar alguém já
|
||||
# derruba a sessão aberta dele.
|
||||
def active_for_authentication?
|
||||
super && ativo?
|
||||
end
|
||||
|
||||
def inactive_message
|
||||
ativo? ? super : :conta_desativada
|
||||
end
|
||||
|
||||
# ── Permissões ──────────────────────────────────────────────
|
||||
# O que o usuário pode fazer sai do PERFIL DE ACESSO (PerfilAcesso), que o ADM
|
||||
# monta na tela. Enquanto ele não tem perfil, vale o padrão do papel legado
|
||||
# (Permissao::PADRAO_POR_ROLE) — é o que mantém o sistema funcionando durante a
|
||||
# migração e o que faz um usuário recém-criado não ficar sem nada.
|
||||
def pode?(chave)
|
||||
permissoes_efetivas.include?(chave.to_s)
|
||||
end
|
||||
|
||||
# Set (não Array): `pode?` é chamado dezenas de vezes por render de tela — uma
|
||||
# vez por item de menu e por botão.
|
||||
#
|
||||
# ⚠️ A primeira linha existe para NÃO tocar o banco quando não há perfil: os
|
||||
# specs de policy usam `build(:admin)` (objeto sem id), e `perfil_acesso`
|
||||
# dispararia consulta com id nulo.
|
||||
def permissoes_efetivas
|
||||
@permissoes_efetivas ||=
|
||||
if perfil_acesso_id.nil?
|
||||
Set.new(Permissao.do_role(role))
|
||||
else
|
||||
Set.new(perfil_acesso&.permissoes_validas || Permissao.do_role(role))
|
||||
end
|
||||
end
|
||||
|
||||
# A coluna nasce numa migration própria. Se o código subir antes de `db:migrate`
|
||||
# rodar, `perfil_acesso_id` não existe e TODA tela quebraria por NoMethodError —
|
||||
# aqui o sistema simplesmente continua no padrão do papel até a coluna chegar.
|
||||
# Pode sair depois que a migration estiver aplicada em produção.
|
||||
def perfil_acesso_id
|
||||
return nil unless self.class.column_names.include?('perfil_acesso_id')
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
# Trocar o perfil no meio da request (tela de usuários) tem que refletir.
|
||||
def recarregar_permissoes!
|
||||
@permissoes_efetivas = nil
|
||||
self
|
||||
end
|
||||
|
||||
# Primeira tela que este usuário pode abrir, na ordem em que o menu as mostra.
|
||||
#
|
||||
# Existe para não haver LOOP: a raiz do app é o dashboard e o tratamento de
|
||||
# "acesso negado" redireciona para a raiz — um perfil sem `dashboard.ver`
|
||||
# entraria em nega → raiz → nega para sempre. `nil` significa "não pode abrir
|
||||
# nada", e quem chama mostra a tela de sem-acesso.
|
||||
HOME_POR_PERMISSAO = [
|
||||
['dashboard.ver', :dashboard_path],
|
||||
['dashboard.operacoes', :operacoes_dashboard_path],
|
||||
['consolidacao.ver', :consolidacoes_path],
|
||||
['admin.usuarios', :admin_usuarios_path],
|
||||
['admin.perfis', :admin_perfis_path],
|
||||
['admin.configuracoes', :admin_configuracoes_path],
|
||||
['admin.romaneio', :admin_romaneios_path],
|
||||
['notificacao.contatos', :admin_contatos_path],
|
||||
['notificacao.eventos', :admin_eventos_path],
|
||||
['notificacao.envios', :admin_envios_path]
|
||||
].freeze
|
||||
|
||||
def home_rota
|
||||
return :motorista_dashboard_path if motorista?
|
||||
|
||||
HOME_POR_PERMISSAO.find { |chave, _| pode?(chave) }&.last
|
||||
end
|
||||
|
||||
# Mantidos porque a navbar e vários controllers já os chamam — agora são só
|
||||
# apelidos das chaves correspondentes.
|
||||
def pode_ver_config?
|
||||
pode?('admin.configuracoes')
|
||||
admin? || gerente?
|
||||
end
|
||||
|
||||
def pode_consolidar?
|
||||
pode?('consolidacao.ver')
|
||||
end
|
||||
|
||||
# ── Trava do último administrador ───────────────────────────
|
||||
# Quem, ATIVO, ainda consegue reabrir a porta do sistema (mexer em perfis e em
|
||||
# usuários). Sem isto, o ADM consegue se trancar para fora: a tela que controla
|
||||
# acesso é justamente a que ele perderia, e a volta só existiria por console.
|
||||
#
|
||||
# `ignorando` simula "e se esta pessoa for desativada, excluída ou trocada de
|
||||
# perfil?" — passe o usuário que está sendo alterado.
|
||||
def self.administradores_de_acesso(ignorando: nil)
|
||||
ativos.includes(:perfil_acesso)
|
||||
.reject { |u| ignorando && u.id == ignorando.id }
|
||||
.select { |u| u.pode?('admin.perfis') && u.pode?('admin.usuarios_gerenciar') }
|
||||
end
|
||||
|
||||
def self.ultimo_administrador?(usuario)
|
||||
return false unless usuario.pode?('admin.perfis') && usuario.pode?('admin.usuarios_gerenciar')
|
||||
|
||||
administradores_de_acesso(ignorando: usuario).empty?
|
||||
admin? || gerente? || operador?
|
||||
end
|
||||
|
||||
# Token de login rápido via QR Code (extrato) — Fase 7/8
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# app/models/variavel_personalizada.rb
|
||||
#
|
||||
# Variável criada pelo ADM, com valor fixo, usável em qualquer mensagem.
|
||||
class VariavelPersonalizada < ApplicationRecord
|
||||
# Nome da tabela fixado: o Inflector pt-BR não resolve variavel → variaveis.
|
||||
self.table_name = 'variaveis_personalizadas'
|
||||
|
||||
# Mesmo formato que o renderizador aceita em {{...}}: letras minúsculas,
|
||||
# números e _ . Sem isso, o ADM cadastraria "Telefone Suporte" e a variável
|
||||
# nunca seria substituída — falha muda dentro da mensagem enviada.
|
||||
FORMATO_CHAVE = /\A[a-z][a-z0-9_]*\z/
|
||||
|
||||
before_validation :normalizar
|
||||
|
||||
validates :chave, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: FORMATO_CHAVE,
|
||||
message: 'use apenas letras minúsculas, números e _ (ex.: telefone_suporte)' }
|
||||
validates :valor, presence: true
|
||||
validate :nao_colidir_com_variavel_do_sistema
|
||||
|
||||
scope :ativas, -> { where(ativo: true) }
|
||||
scope :ordenadas, -> { order(:chave) }
|
||||
|
||||
def marcador = "{{#{chave}}}"
|
||||
|
||||
private
|
||||
|
||||
def normalizar
|
||||
self.chave = chave.to_s.strip.downcase.tr(' ', '_')
|
||||
self.valor = valor.to_s.strip
|
||||
end
|
||||
|
||||
# Deixar o ADM criar {{valor}} ou {{motorista}} faria a variável dele
|
||||
# sobrescrever (ou ser sobrescrita por) a do sistema, dependendo da ordem —
|
||||
# exatamente o tipo de bug que não aparece no preview e aparece no envio.
|
||||
def nao_colidir_com_variavel_do_sistema
|
||||
return if chave.blank?
|
||||
|
||||
reservadas = Notificacao::CatalogoVariaveis::SISTEMA.keys +
|
||||
Notificacao::CatalogoVariaveis::CONTEXTO.keys +
|
||||
Notificacao::CatalogoVariaveis::DADOS.keys
|
||||
return unless reservadas.include?(chave)
|
||||
|
||||
errors.add(:chave, "já existe no sistema — escolha outro nome (ex.: #{chave}_empresa)")
|
||||
end
|
||||
end
|
||||
@@ -15,15 +15,7 @@ class ApplicationPolicy
|
||||
def edit? = update?
|
||||
def destroy? = user.admin?
|
||||
|
||||
# Régua padrão de TODAS as policies: pergunta ao perfil de acesso do usuário
|
||||
# (User#pode?), não ao papel dele. As chaves estão em Permissao::TODAS.
|
||||
def pode?(chave)
|
||||
user.pode?(chave)
|
||||
end
|
||||
|
||||
# Helpers de papel — LEGADO. Continuam aqui porque `admin?` ainda distingue
|
||||
# tipo de conta em pontos que não são permissão (ex.: escopo de listagem).
|
||||
# Regra nova não deve usá-los: use `pode?`.
|
||||
# Helpers de papel disponíveis para todas as policies (delegam para o User)
|
||||
def admin?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# app/policies/auditoria_log_policy.rb
|
||||
#
|
||||
# Existe para a auditoria parar de cair no default genérico da ApplicationPolicy
|
||||
# (`policy_class: ApplicationPolicy` no controller): a régua de lá é a de
|
||||
# consolidação, e quem podia consolidar entrava na auditoria pela URL, embora o
|
||||
# menu nunca oferecesse.
|
||||
class AuditoriaLogPolicy < ApplicationPolicy
|
||||
def index? = pode?('admin.auditoria')
|
||||
def show? = index?
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
user.pode?('admin.auditoria') ? scope.all : scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,22 @@
|
||||
# app/policies/configuracao_notificacao_policy.rb
|
||||
#
|
||||
# Credenciais de SMTP/Twilio e limites de envio.
|
||||
# Mais restrito que ConfiguracaoPolicy (que libera index? para gerente): estas
|
||||
# são credenciais de envio — senha de e-mail e token do Twilio —, então só ADM.
|
||||
#
|
||||
# ⚠️ Cada action nova do controller precisa de um método `<action>?` aqui,
|
||||
# senão o `authorize` estoura.
|
||||
class ConfiguracaoNotificacaoPolicy < ApplicationPolicy
|
||||
def show? = pode?('notificacao.credenciais')
|
||||
def update? = show?
|
||||
def show?
|
||||
admin?
|
||||
end
|
||||
|
||||
def update?
|
||||
admin?
|
||||
end
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
scope.all
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
# app/policies/configuracao_policy.rb
|
||||
class ConfiguracaoPolicy < ApplicationPolicy
|
||||
def index? = pode?('admin.configuracoes')
|
||||
def show? = index?
|
||||
|
||||
# Alterar preço muda o valor de todo fechamento daqui para a frente — chave
|
||||
# própria, separada de só olhar a tabela.
|
||||
def edit? = pode?('admin.configuracoes_editar')
|
||||
def update? = edit?
|
||||
def index?
|
||||
admin_ou_gerente?
|
||||
end
|
||||
|
||||
def edit?
|
||||
admin?
|
||||
end
|
||||
|
||||
def update?
|
||||
admin?
|
||||
end
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
scope.all
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,42 +1,20 @@
|
||||
# app/policies/consolidacao_policy.rb
|
||||
class ConsolidacaoPolicy < ApplicationPolicy
|
||||
def index? = pode?('consolidacao.ver')
|
||||
def arquivadas? = index?
|
||||
def show? = index?
|
||||
def create? = pode?('consolidacao.criar')
|
||||
def new? = create?
|
||||
def index? = user.pode_consolidar?
|
||||
def arquivadas? = user.pode_consolidar?
|
||||
def show? = user.pode_consolidar?
|
||||
def create? = user.pode_consolidar?
|
||||
def update? = user.pode_consolidar? && !record_finalizada_para_operador?
|
||||
def destroy? = user.admin? || user.gerente?
|
||||
|
||||
# Editar uma consolidação JÁ FINALIZADA é outra permissão: mexer depois do
|
||||
# fechamento muda o valor que o motorista vai receber. Antes isso era "operador
|
||||
# não pode, gerente pode" cravado no código.
|
||||
def update?
|
||||
pode?('consolidacao.editar') && (!record_finalizada? || pode?('consolidacao.editar_finalizada'))
|
||||
end
|
||||
|
||||
def edit? = update?
|
||||
|
||||
def finalizar? = pode?('consolidacao.finalizar')
|
||||
def finalizar_parcial? = finalizar?
|
||||
|
||||
def arquivar? = pode?('consolidacao.arquivar')
|
||||
def reativar? = arquivar?
|
||||
|
||||
def destroy? = pode?('consolidacao.excluir')
|
||||
def excluir_motorista? = destroy?
|
||||
|
||||
# Pagamento: registrar e estornar são chaves distintas — estornar desfaz algo
|
||||
# que já foi comunicado ao motorista.
|
||||
def registrar_pagamento? = pode?('consolidacao.registrar_pagamento')
|
||||
def cancelar_pagamento? = pode?('consolidacao.cancelar_pagamento')
|
||||
|
||||
# Quem entra e quem sai do fechamento (sem excluir de vez).
|
||||
def gerir_motoristas? = pode?('consolidacao.gerir_motoristas') && update?
|
||||
|
||||
def exportar_pdf? = pode?('consolidacao.exportar_pdf')
|
||||
# Registrar/estornar pagamento: mesma régua de arquivar/excluir.
|
||||
def registrar_pagamento? = user.admin? || user.gerente?
|
||||
def cancelar_pagamento? = registrar_pagamento?
|
||||
|
||||
private
|
||||
|
||||
def record_finalizada?
|
||||
record.respond_to?(:finalizada?) && record.finalizada?
|
||||
# Operador não mexe em consolidação finalizada
|
||||
def record_finalizada_para_operador?
|
||||
record.respond_to?(:finalizada?) && record.finalizada? && user.operador?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# app/policies/contato_policy.rb
|
||||
#
|
||||
# Contatos e grupos (GrupoContatoPolicy herda esta) — quem RECEBE as mensagens.
|
||||
class ContatoPolicy < ApplicationPolicy
|
||||
def index? = pode?('notificacao.contatos')
|
||||
def show? = index?
|
||||
|
||||
def create? = pode?('notificacao.contatos_gerenciar')
|
||||
def new? = create?
|
||||
def update? = create?
|
||||
def edit? = update?
|
||||
|
||||
# Excluir destinatário é separado de editar: some do histórico de quem recebe.
|
||||
def destroy? = pode?('notificacao.contatos_excluir')
|
||||
|
||||
# Grupos do WhatsApp carregados sob demanda pelo form.
|
||||
def grupos_whatsapp? = create?
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
user.pode?('notificacao.contatos') ? scope.all : scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,17 +1,15 @@
|
||||
# app/policies/dashboard_policy.rb
|
||||
#
|
||||
# Policy headless (`authorize :dashboard, :x?`). Deixou de ser Struct para herdar
|
||||
# o `pode?` da ApplicationPolicy.
|
||||
class DashboardPolicy < ApplicationPolicy
|
||||
def ver? = pode?('dashboard.ver')
|
||||
def index? = ver?
|
||||
|
||||
# Custos, pagamentos, relatório financeiro e entregas em aberto. Era o buraco
|
||||
# do `externo`: o controller usava skip_authorization e ele via tudo.
|
||||
def financeiro? = pode?('dashboard.financeiro')
|
||||
def pendentes? = financeiro?
|
||||
|
||||
def operacoes? = pode?('dashboard.operacoes')
|
||||
def exportar? = pode?('dashboard.exportar')
|
||||
def metricas? = pode?('dashboard.metricas')
|
||||
class DashboardPolicy < Struct.new(:user, :dashboard)
|
||||
def metricas?
|
||||
user.admin? || user.gerente? || user.operador?
|
||||
end
|
||||
|
||||
def index?
|
||||
user.admin? || user.gerente?
|
||||
end
|
||||
|
||||
# Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML).
|
||||
def operacoes?
|
||||
!user.motorista?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# app/policies/edicao_lancamento_policy.rb
|
||||
#
|
||||
# Policy headless (`authorize :edicao_lancamento`). Escreve na API do SimpliRoute:
|
||||
# altera o dado de ORIGEM do rastreio, então é chave própria.
|
||||
# Correção de lançamento grava DIRETO na base de produção do SimpliRoute —
|
||||
# operação sensível e irreversível. Restrita a admin.
|
||||
# Policy "headless": autorizada com o símbolo :edicao_lancamento (sem model).
|
||||
class EdicaoLancamentoPolicy < ApplicationPolicy
|
||||
def show? = pode?('admin.edicao_lancamento')
|
||||
def buscar? = show?
|
||||
def carregar? = show?
|
||||
def atualizar? = show?
|
||||
def historico? = show?
|
||||
# ⚠️ Uma entrada por action do controller: o Pundit chama "<action>?" e, se o
|
||||
# método não existir, levanta NoMethodError (500) — foi o que aconteceu ao
|
||||
# adicionar `carregar` sem passar por aqui. Ação nova no controller ⇒ linha
|
||||
# nova aqui.
|
||||
def show? = admin?
|
||||
def buscar? = admin?
|
||||
def carregar? = admin?
|
||||
def atualizar? = admin?
|
||||
def historico? = admin?
|
||||
end
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# app/policies/evento_notificacao_policy.rb
|
||||
class EventoNotificacaoPolicy < ApplicationPolicy
|
||||
def index? = pode?('notificacao.eventos')
|
||||
def show? = index?
|
||||
|
||||
def create? = pode?('notificacao.eventos_gerenciar')
|
||||
def new? = create?
|
||||
def update? = create?
|
||||
def edit? = update?
|
||||
|
||||
# Disparar manda mensagem DE VERDADE, em nome da empresa — chave própria,
|
||||
# separada de configurar o evento.
|
||||
def disparar? = pode?('notificacao.disparar_manual')
|
||||
|
||||
# Evento de sistema não é apagável: o código dispara por ele.
|
||||
def destroy?
|
||||
create? && record.respond_to?(:apagavel?) && record.apagavel?
|
||||
end
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
user.pode?('notificacao.eventos') ? scope.all : scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
# app/policies/grupo_contato_policy.rb — ver ContatoPolicy.
|
||||
class GrupoContatoPolicy < ContatoPolicy
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
# app/policies/notificacao_envio_policy.rb
|
||||
#
|
||||
# Histórico do que saiu (e do que falhou).
|
||||
class NotificacaoEnvioPolicy < ApplicationPolicy
|
||||
def index? = pode?('notificacao.envios')
|
||||
def show? = index?
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
user.pode?('notificacao.envios') ? scope.all : scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,24 +0,0 @@
|
||||
# app/policies/perfil_acesso_policy.rb
|
||||
#
|
||||
# Quem monta perfil decide o acesso de todo mundo — é a permissão mais sensível
|
||||
# do sistema, por isso ela é uma chave própria (`admin.perfis`) e não vem junto
|
||||
# de "gerenciar usuários".
|
||||
class PerfilAcessoPolicy < ApplicationPolicy
|
||||
def index? = user.pode?('admin.perfis')
|
||||
def show? = index?
|
||||
def create? = index?
|
||||
def new? = create?
|
||||
def update? = index?
|
||||
def edit? = update?
|
||||
|
||||
# Perfil de sistema ou em uso não sai (ver PerfilAcesso#apagavel?).
|
||||
def destroy?
|
||||
index? && record.respond_to?(:apagavel?) && record.apagavel?
|
||||
end
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
user.pode?('admin.perfis') ? scope.all : scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,9 @@
|
||||
# app/policies/planilha_simpli_route_policy.rb
|
||||
# app/policies/planilha_simpliroute_policy.rb
|
||||
#
|
||||
# Policy headless (`authorize :planilha_simpli_route`).
|
||||
# Geração da planilha de carga do SimpliRoute — tarefa de preparação de operação.
|
||||
# Liberada para admin e gerente (quem prepara as cargas). Policy "headless"
|
||||
# (autorizada com o símbolo :planilha_simpliroute).
|
||||
class PlanilhaSimpliRoutePolicy < ApplicationPolicy
|
||||
def show? = pode?('admin.planilha_simpli_route')
|
||||
def baixar? = show?
|
||||
def show? = admin_ou_gerente?
|
||||
def baixar? = admin_ou_gerente?
|
||||
end
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# app/policies/romaneio_policy.rb
|
||||
#
|
||||
# Uma chave por TELA, como manda o comentário de Permissao: importar o plano, editar
|
||||
# a tabela e imprimir são a MESMA operação do ponto de vista de quem carrega o
|
||||
# caminhão — quebrar mais fino encheria a tela de perfil de caixinhas que ninguém
|
||||
# sabe responder.
|
||||
class RomaneioPolicy < ApplicationPolicy
|
||||
def index? = pode?('admin.romaneio')
|
||||
def show? = index?
|
||||
def create? = index?
|
||||
def destroy? = index?
|
||||
|
||||
def importar_planilha? = index?
|
||||
def reimportar? = index?
|
||||
def vincular_operacao? = index?
|
||||
def pdf? = index?
|
||||
def atualizar_linha? = index?
|
||||
def atualizar_veiculo? = index?
|
||||
def atualizar_rotulo? = index?
|
||||
def atualizar_logo? = index?
|
||||
end
|
||||
@@ -1,35 +1,44 @@
|
||||
# app/policies/user_policy.rb
|
||||
class UserPolicy < ApplicationPolicy
|
||||
def index? = pode?('admin.usuarios')
|
||||
def index?
|
||||
admin_ou_gerente?
|
||||
end
|
||||
|
||||
# Cada um enxerga a própria ficha (é assim que a tela de perfil pessoal
|
||||
# funciona), além de quem administra usuários.
|
||||
def show? = pode?('admin.usuarios') || record == user
|
||||
def show?
|
||||
admin? || record == user
|
||||
end
|
||||
|
||||
def create? = pode?('admin.usuarios_gerenciar')
|
||||
def new? = create?
|
||||
def create?
|
||||
admin?
|
||||
end
|
||||
|
||||
# ⚠️ `record == user` CONTINUA aqui de propósito: é o que permite alguém editar
|
||||
# o próprio cadastro (nome, senha). O que NÃO pode é essa mesma pessoa mudar o
|
||||
# próprio papel/perfil e se promover — isso é barrado em #alterar_acesso?, que
|
||||
# o controller usa para decidir quais campos aceita.
|
||||
def update? = pode?('admin.usuarios_gerenciar') || record == user
|
||||
def edit? = update?
|
||||
def new?
|
||||
create?
|
||||
end
|
||||
|
||||
def destroy? = create? && record != user
|
||||
def toggle_ativo? = create? && record != user
|
||||
def update?
|
||||
admin? || record == user
|
||||
end
|
||||
|
||||
# Pode mexer no TIPO DE CONTA e no PERFIL DE ACESSO de alguém? Nunca no
|
||||
# próprio: senão qualquer usuário se promove editando a própria ficha.
|
||||
def alterar_acesso? = pode?('admin.usuarios_gerenciar') && record != user
|
||||
def edit?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
admin? && record != user
|
||||
end
|
||||
|
||||
def toggle_ativo?
|
||||
admin?
|
||||
end
|
||||
|
||||
# admin? e admin_ou_gerente? vêm da ApplicationPolicy.
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
# Continua por papel: filtrar por permissão exigiria carregar o perfil de
|
||||
# cada linha (N+1) para responder algo que a policy já cobre na ação.
|
||||
if user.admin?
|
||||
scope.all
|
||||
elsif user.pode?('admin.usuarios')
|
||||
elsif user.gerente?
|
||||
scope.where.not(role: :admin)
|
||||
else
|
||||
scope.where(id: user.id)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# app/policies/variavel_personalizada_policy.rb
|
||||
#
|
||||
# Variável personalizada entra no texto de mensagens enviadas em nome da empresa
|
||||
# — mesma régua de quem configura evento.
|
||||
class VariavelPersonalizadaPolicy < ApplicationPolicy
|
||||
def index? = pode?('notificacao.variaveis')
|
||||
def show? = index?
|
||||
def create? = index?
|
||||
def new? = create?
|
||||
def update? = index?
|
||||
def edit? = update?
|
||||
def destroy? = index?
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
user.pode?('notificacao.variaveis') ? scope.all : scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
# app/policies/whatsapp_sessao_policy.rb
|
||||
#
|
||||
# Policy headless (`authorize :whatsapp_sessao`). Parear o WhatsApp é o que
|
||||
# habilita mandar mensagem em nome da empresa.
|
||||
class WhatsappSessaoPolicy < ApplicationPolicy
|
||||
def show? = pode?('notificacao.whatsapp_sessao')
|
||||
def desconectar? = show?
|
||||
def testar? = show?
|
||||
end
|
||||
@@ -1,189 +0,0 @@
|
||||
# app/services/analytics/notas_fora_operacao.rb
|
||||
#
|
||||
# NFs que o motorista entregou no período mas que NÃO estão em nenhuma planilha
|
||||
# de operação (`gade_entregas_*`) — as notas que entram por plano avulso ou de
|
||||
# inclusão, fora do carregamento original do cliente.
|
||||
#
|
||||
# Elas contam no dashboard financeiro (o motorista foi ao local e recebe por
|
||||
# isso) e SUMIAM do dashboard de operações, porque lá o INNER JOIN com a tabela
|
||||
# da operação simplesmente as descarta. Era metade da divergência entre as duas
|
||||
# telas — agora aparece como painel próprio em vez de virar diferença silenciosa.
|
||||
#
|
||||
# SEGURANÇA: os nomes das tabelas passam pela whitelist (Operacao.nomes_validos,
|
||||
# que lê o catálogo) + quote_table_name. Bases SOMENTE LEITURA.
|
||||
module Analytics
|
||||
class NotasForaOperacao
|
||||
# Teto da listagem na tela (os totais continuam contando tudo).
|
||||
LIMITE = 300
|
||||
|
||||
# Colunas extras do espelho que a tela usa. Nem toda base tem todas — as
|
||||
# ausentes viram NULL, mesmo padrão de selects_gade em OperacaoMetricas.
|
||||
# Whitelist fixa: nada aqui vem do usuário.
|
||||
COLUNAS_EXTRA = %w[title notes comments route_id].freeze
|
||||
|
||||
# Candidatas ao nome do PLANO de origem ("(Avulsa)", "INCLUSÃO"...), na ordem
|
||||
# de preferência.
|
||||
#
|
||||
# ⚠️ `title` NÃO entra aqui: conferido em 24/08/2026 com dado real, ele traz
|
||||
# "NF 89096 - KAIQUE TAUAN DA SILVA" — o formato da coluna A da planilha de
|
||||
# importação (`NF {nota_fiscal} - {nome_completo}`), ou seja, o destinatário.
|
||||
# Agrupar por ele daria um grupo por NF, o que não informa nada.
|
||||
COLUNAS_PLANO = %w[notes comments route_id].freeze
|
||||
|
||||
def initialize(inicio:, fim:)
|
||||
@inicio = inicio&.to_date
|
||||
@fim = fim&.to_date
|
||||
end
|
||||
|
||||
# Visitas cruas (pode haver mais de uma por NF).
|
||||
def visitas
|
||||
@visitas ||= carregar
|
||||
end
|
||||
|
||||
# Uma linha por NF: a última visita dela. Mesmo critério de OperacaoMetricas.
|
||||
def linhas
|
||||
@linhas ||= visitas.group_by { |r| r['reference_id'].to_s }
|
||||
.values
|
||||
.map { |vs| vs.max_by { |r| ordem_visita(r) } }
|
||||
.sort_by { |r| r['checkout'].to_s }
|
||||
.reverse
|
||||
end
|
||||
|
||||
def total
|
||||
linhas.size
|
||||
end
|
||||
|
||||
def entregues
|
||||
linhas.count { |r| r['status'] == 'completed' }
|
||||
end
|
||||
|
||||
def nao_entregues
|
||||
linhas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||
end
|
||||
|
||||
def pendentes
|
||||
total - entregues - nao_entregues
|
||||
end
|
||||
|
||||
def any?
|
||||
total.positive?
|
||||
end
|
||||
|
||||
# Nome do plano de origem: primeira coluna de COLUNAS_PLANO preenchida.
|
||||
# nil quando o espelho não guarda essa informação.
|
||||
def plano(registro)
|
||||
COLUNAS_PLANO.each do |coluna|
|
||||
valor = registro[coluna].to_s.strip
|
||||
return valor if valor.present?
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
# Destinatário/título da visita (coluna `title` do espelho).
|
||||
def titulo(registro)
|
||||
registro['title'].to_s.strip.presence
|
||||
end
|
||||
|
||||
def plano_identificado?
|
||||
linhas.any? { |r| plano(r) }
|
||||
end
|
||||
|
||||
# Quebra do painel em grupos. Quando o espelho traz o plano, separa
|
||||
# "(Avulsa)" de "INCLUSÃO"; quando não traz, agrupar por título daria um
|
||||
# grupo por NF — então cai para a unidade, que ainda diz algo útil.
|
||||
# Devolve [rótulo do agrupamento, [{ nome:, total: }, ...]].
|
||||
def agrupamento
|
||||
if plano_identificado?
|
||||
['Plano', agrupar { |r| plano(r) || 'SEM PLANO' }]
|
||||
else
|
||||
['Unidade', agrupar { |r| r['contact_name'].to_s.strip.presence || 'SEM UNIDADE' }]
|
||||
end
|
||||
end
|
||||
|
||||
# Rótulo legível do resultado da última visita.
|
||||
RESULTADOS = { 'completed' => 'Entregue', 'failed' => 'Não entregue',
|
||||
'pending' => 'Em aberto', 'in_progress' => 'Em rota' }.freeze
|
||||
|
||||
def resultado(registro)
|
||||
status = registro['status'].to_s
|
||||
RESULTADOS[status] || status.presence || 'sem status'
|
||||
end
|
||||
|
||||
def sucesso?(registro)
|
||||
registro['status'] == 'completed'
|
||||
end
|
||||
|
||||
def listagem
|
||||
linhas.first(LIMITE)
|
||||
end
|
||||
|
||||
def truncada?
|
||||
total > LIMITE
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agrupar
|
||||
linhas.group_by { |r| yield(r) }
|
||||
.map { |nome, rows| { nome: nome, total: rows.size } }
|
||||
.sort_by { |h| -h[:total] }
|
||||
end
|
||||
|
||||
def ordem_visita(registro)
|
||||
t = registro['checkout'].presence&.to_time
|
||||
[t ? 1 : 0, t || Time.at(0)]
|
||||
rescue ArgumentError, TypeError
|
||||
[0, Time.at(0)]
|
||||
end
|
||||
|
||||
def carregar
|
||||
tabelas = Operacao.nomes_validos
|
||||
return [] if tabelas.empty?
|
||||
|
||||
conn = ActiveRecord::Base.connection
|
||||
rastreio = conn.quote_table_name(Entrega.table_name)
|
||||
conta = Entrega.condicao_conta_sql('r')
|
||||
|
||||
# `nota_fiscal IS NOT NULL` é OBRIGATÓRIO: um único NULL na subquery faz o
|
||||
# NOT IN devolver ZERO linhas (semântica de três valores do SQL) e o painel
|
||||
# apareceria vazio para sempre.
|
||||
conhecidas = tabelas.map do |t|
|
||||
"SELECT nota_fiscal FROM #{conn.quote_table_name(t)} WHERE nota_fiscal IS NOT NULL"
|
||||
end.join(' UNION ')
|
||||
|
||||
sql = <<~SQL
|
||||
SELECT r.tracking_id, r.reference_id, r.driver, r.vehicle, r.status, r.observation,
|
||||
r.contact_name, r.address, r.checkout, r.planned_date,
|
||||
#{selects_plano(conn)}
|
||||
FROM #{rastreio} r
|
||||
WHERE r.reference_id IS NOT NULL
|
||||
#{conta ? "AND #{conta}" : ''}
|
||||
#{filtro_periodo(conn)}
|
||||
AND r.reference_id::text NOT IN (#{conhecidas})
|
||||
SQL
|
||||
|
||||
conn.select_all(sql).to_a
|
||||
end
|
||||
|
||||
# Colunas extras que existirem de fato; as demais viram NULL com o mesmo
|
||||
# alias, para a leitura da linha não precisar saber quais existem.
|
||||
def selects_plano(conn)
|
||||
existentes = conn.columns(Entrega.table_name).map(&:name)
|
||||
COLUNAS_EXTRA.map do |coluna|
|
||||
existentes.include?(coluna) ? "r.#{coluna}" : "CAST(NULL AS text) AS #{coluna}"
|
||||
end.join(', ')
|
||||
end
|
||||
|
||||
# Mesmo recorte de OperacaoMetricas#filtro_periodo: atendidas pela data real
|
||||
# (checkout); em aberto (sem checkout) pela data planejada.
|
||||
def filtro_periodo(conn)
|
||||
return '' unless @inicio && @fim
|
||||
|
||||
ini = conn.quote(@inicio)
|
||||
fim_excl = conn.quote(@fim + 1)
|
||||
fim_dia = conn.quote(@fim.end_of_day)
|
||||
"AND ((r.checkout >= #{ini} AND r.checkout < #{fim_excl})" \
|
||||
" OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia}))"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,20 +2,12 @@
|
||||
#
|
||||
# Núcleo de dados do "Dashboard de Operações".
|
||||
#
|
||||
# Espelha a query de gestão do cliente: casa db_reem_simplerout_2026 com a(s)
|
||||
# tabela(s) de operação (gade_entregas_*) por reference_id::text = nota_fiscal e
|
||||
# agrega em Ruby para alimentar os painéis (KPIs, insucessos %, índices de falha,
|
||||
# status, motoristas, STS, por dia e o mapa de calor).
|
||||
#
|
||||
# DUAS CAMADAS, de propósito:
|
||||
# #visitas — uma linha por ida do motorista ao local. É o que o dashboard
|
||||
# financeiro conta (Entrega.contar_atendidas) e o que se paga ao
|
||||
# motorista.
|
||||
# #linhas — uma linha por NOTA FISCAL (a última visita de cada uma). É o que
|
||||
# o cliente paga, o que confere com os documentos físicos e com a
|
||||
# aba ENTREGAS da planilha. TODOS os KPIs desta tela saem daqui.
|
||||
# Os dois números só coincidem quando nenhuma NF precisou de segunda ida; a
|
||||
# diferença aparece na tela como "retentativas" em vez de ficar escondida.
|
||||
# Espelha a query de gestão do cliente: pega o ÚLTIMO status de cada NF em
|
||||
# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz
|
||||
# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por
|
||||
# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os
|
||||
# painéis (KPIs, insucessos %, índices de falha, status, motoristas, STS, por dia
|
||||
# e o mapa de calor).
|
||||
#
|
||||
# SEGURANÇA: os nomes das tabelas de operação só entram no SQL depois de passar
|
||||
# pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão
|
||||
@@ -44,30 +36,15 @@ module Analytics
|
||||
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
||||
end
|
||||
|
||||
# VISITAS após o cross-filter: uma linha por passagem do motorista. Uma mesma
|
||||
# NF pode ter várias (falhou dia 10, entregou dia 12). É a base da análise
|
||||
# operacional — quantas idas ao local foram necessárias.
|
||||
def visitas
|
||||
@visitas ||= begin
|
||||
# Linhas após o cross-filter — base de TODAS as agregações/KPIs.
|
||||
def linhas
|
||||
@linhas ||= begin
|
||||
base = @filtros.empty? ? registros
|
||||
: registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
|
||||
@data ? base.select { |r| data_de(r['checkout']) == @data } : base
|
||||
end
|
||||
end
|
||||
|
||||
# NOTAS FISCAIS: uma linha por NF, com a ÚLTIMA visita dela. É a base de
|
||||
# TODOS os KPIs desta tela, porque é o que confere com os documentos físicos
|
||||
# e com a aba ENTREGAS da planilha entregue ao cliente (Analytics::
|
||||
# PlanilhaEntregas também usa "último status por reference_id").
|
||||
#
|
||||
# ⚠️ O dedup precisa acontecer AQUI — depois do período e do cross-filter — e
|
||||
# não no SQL. Enquanto ele rodava como ROW_NUMBER + `rn = 1` sobre a tabela
|
||||
# INTEIRA, uma NF reentregue DEPOIS do fim do período perdia a visita que
|
||||
# estava DENTRO dele e sumia da contagem do mês.
|
||||
def linhas
|
||||
@linhas ||= por_nota.values.map { |vs| vs.max_by { |r| ordem_visita(r) } }
|
||||
end
|
||||
|
||||
# Campos pesquisáveis da tabela espelho (planilha da operação).
|
||||
BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name
|
||||
address nome_completo endereco_completo status_gade operacao].freeze
|
||||
@@ -126,50 +103,6 @@ module Analytics
|
||||
total - sucesso - recusas
|
||||
end
|
||||
|
||||
# NFs da operação cujo resultado caiu dentro de uma faixa de datas —
|
||||
# REFERÊNCIA para o cabeçalho da tela de operação única, onde o total
|
||||
# continua sendo a operação inteira (é o que confere com a planilha do
|
||||
# cliente). Mesma regra do filtro SQL: concluídas/falhas pela data real
|
||||
# (checkout), pendentes pela data planejada.
|
||||
def total_no_periodo(inicio, fim)
|
||||
return total unless inicio && fim
|
||||
|
||||
ini = inicio.to_date
|
||||
fim = fim.to_date
|
||||
linhas.count do |r|
|
||||
d = data_de(r['checkout']) || data_de(r['planned_date'])
|
||||
d && d >= ini && d <= fim
|
||||
end
|
||||
end
|
||||
|
||||
# ── Camada operacional (por VISITA, não por NF) ───────────────
|
||||
# Os KPIs acima contam NOTAS porque é o que o cliente paga e confere. Estes
|
||||
# contam IDAS AO LOCAL — é o que o motorista recebe e o que o dashboard
|
||||
# financeiro usa (Entrega.contar_atendidas conta linhas). Sem eles, uma NF
|
||||
# que falhou duas vezes antes de ser entregue aparecia como 100% de sucesso
|
||||
# e o insucesso sumia da tela.
|
||||
def total_visitas
|
||||
visitas.size
|
||||
end
|
||||
|
||||
# Idas ao local além da primeira de cada NF.
|
||||
def retentativas
|
||||
total_visitas - total
|
||||
end
|
||||
|
||||
def visitas_insucesso
|
||||
visitas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||
end
|
||||
|
||||
# NFs que hoje constam como ENTREGUES mas custaram mais de uma ida — a
|
||||
# informação que o dedup escondia por completo.
|
||||
def notas_reentregues
|
||||
@notas_reentregues ||= por_nota.count do |_nf, vs|
|
||||
vs.any? { |r| Entrega::STATUS_FALHA.include?(r['status']) } &&
|
||||
vs.max_by { |r| ordem_visita(r) }['status'] == 'completed'
|
||||
end
|
||||
end
|
||||
|
||||
# Donut "Insucessos %": completas vs falhas (sobre o total).
|
||||
def insucessos_pct
|
||||
{
|
||||
@@ -273,27 +206,6 @@ module Analytics
|
||||
|
||||
private
|
||||
|
||||
# NF => visitas dela (já filtradas). Chave string: reference_id é numérico no
|
||||
# espelho e texto na tabela da operação.
|
||||
def por_nota
|
||||
@por_nota ||= visitas.group_by { |r| r['reference_id'].to_s }
|
||||
end
|
||||
|
||||
# Ordem de "última visita": quem tem checkout ganha de quem não tem e, entre
|
||||
# as com checkout, vence a mais recente. Mesma regra do ROW_NUMBER que a
|
||||
# planilha do cliente usa (checkout DESC NULLS LAST).
|
||||
def ordem_visita(registro)
|
||||
t = tempo_de(registro['checkout'])
|
||||
[t ? 1 : 0, t || Time.at(0)]
|
||||
end
|
||||
|
||||
def tempo_de(valor)
|
||||
return nil if valor.nil? || valor.to_s.strip.empty?
|
||||
valor.to_time
|
||||
rescue ArgumentError, TypeError, NoMethodError
|
||||
nil
|
||||
end
|
||||
|
||||
def datas_checkout
|
||||
@datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
|
||||
end
|
||||
@@ -335,33 +247,35 @@ module Analytics
|
||||
|
||||
conn = ActiveRecord::Base.connection
|
||||
|
||||
rastreio = conn.quote_table_name(Entrega.table_name)
|
||||
conta = Entrega.condicao_conta_sql('r')
|
||||
|
||||
unions = @tabelas.map do |tabela|
|
||||
gade = conn.quote_table_name(tabela)
|
||||
label = conn.quote(Operacao.label(tabela))
|
||||
<<~SQL.strip
|
||||
SELECT #{label} AS operacao,
|
||||
r.tracking_id,
|
||||
r.reference_id, r.driver, r.vehicle, r.status, r.observation, r.contact_name, r.address,
|
||||
r.checkout, r.planned_date, r.foto_da_fachada,
|
||||
r.latitude, r.longitude,
|
||||
r.checkout_latitude, r.checkout_longitude,
|
||||
#{selects_gade(conn, tabela)}
|
||||
FROM #{rastreio} r
|
||||
FROM ultimo r
|
||||
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
||||
WHERE r.reference_id IS NOT NULL#{conta ? " AND #{conta}" : ''}#{filtro_periodo(conn)}
|
||||
WHERE r.rn = 1#{filtro_periodo(conn)}
|
||||
SQL
|
||||
end
|
||||
|
||||
# Traz TODAS as visitas; a redução para uma linha por NF é feita em #linhas,
|
||||
# já depois do período e do cross-filter (ver o comentário lá).
|
||||
#
|
||||
# `uniq` por tracking_id: se a mesma nota_fiscal estiver repetida dentro de
|
||||
# uma tabela de operação (ou em duas tabelas do UNION), o INNER JOIN
|
||||
# devolveria a MESMA visita mais de uma vez e inflaria a contagem.
|
||||
conn.select_all(unions.join("\nUNION ALL\n")).to_a.uniq { |r| r['tracking_id'] }
|
||||
# Sem filtro de conta: o INNER JOIN com a tabela da operação (gade_entregas_*)
|
||||
# já restringe aos dados do cliente. Mantém a query idêntica à de gestão.
|
||||
sql = <<~SQL
|
||||
WITH ultimo AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
|
||||
FROM #{conn.quote_table_name(Entrega.table_name)}
|
||||
WHERE reference_id IS NOT NULL
|
||||
)
|
||||
#{unions.join("\nUNION ALL\n")}
|
||||
SQL
|
||||
|
||||
conn.select_all(sql).to_a
|
||||
end
|
||||
|
||||
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# app/services/analytics/resumo_operacao.rb
|
||||
#
|
||||
# Os números da operação num período, na forma que a mensagem agendada usa —
|
||||
# as MESMAS chaves que Notificacao::Variaveis anuncia no editor para o gatilho
|
||||
# `agendado`.
|
||||
#
|
||||
# Reusa OperacaoMetricas (visão Global) e NotasForaOperacao em vez de refazer as
|
||||
# queries: se a regra de contagem mudar num lugar, o resumo enviado por WhatsApp
|
||||
# não pode continuar dizendo outra coisa que o dashboard.
|
||||
module Analytics
|
||||
class ResumoOperacao
|
||||
def initialize(inicio:, fim:)
|
||||
@inicio = inicio.to_date
|
||||
@fim = fim.to_date
|
||||
end
|
||||
|
||||
# Valores como TEXTO: eles vão direto para dentro de uma mensagem.
|
||||
def numeros
|
||||
{
|
||||
'operacao' => rotulo_operacao,
|
||||
'entregues' => metricas.sucesso.to_s,
|
||||
'recusas' => metricas.recusas.to_s,
|
||||
'pendentes' => metricas.pendentes.to_s,
|
||||
'retentativas' => metricas.retentativas.to_s,
|
||||
'fora_operacao' => fora.total.to_s
|
||||
}
|
||||
end
|
||||
|
||||
# Nada mudou no período? Evita mandar resumo zerado num feriado.
|
||||
def vazio?
|
||||
metricas.total.zero? && fora.total.zero?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def tabelas = @tabelas ||= Operacao.nomes_validos
|
||||
|
||||
def rotulo_operacao
|
||||
tabelas.size == 1 ? Operacao.label(tabelas.first) : "Todas as operações (#{tabelas.size})"
|
||||
end
|
||||
|
||||
def metricas
|
||||
@metricas ||= OperacaoMetricas.new(tabelas: tabelas, inicio: @inicio, fim: @fim)
|
||||
end
|
||||
|
||||
def fora
|
||||
@fora ||= NotasForaOperacao.new(inicio: @inicio, fim: @fim)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,135 +0,0 @@
|
||||
# app/services/analytics/totais_por_motorista.rb
|
||||
#
|
||||
# Quanto cada motorista somou no período — e QUAIS consolidações formam esse
|
||||
# total. É a fonte única das duas telas de "total por motorista":
|
||||
#
|
||||
# • admin → /consolidacoes?visao=motoristas (modo de visualização da própria
|
||||
# lista, com drill-down no motorista clicado)
|
||||
# • motorista→ /motorista (o painel, já recortado nele — mesma conta, mesmo PDF)
|
||||
#
|
||||
# RECORTE — É O MESMO DO DASHBOARD FINANCEIRO, de propósito
|
||||
# (DashboardController#carregar_dados_financeiro). Os dois números são lidos lado
|
||||
# a lado pelo operador; quando divergem, a leitura é "o sistema está errado".
|
||||
# Entram as consolidações ATIVAS que:
|
||||
# • CRUZAM o período (data_inicio <= fim AND data_fim >= inicio) — um
|
||||
# fechamento de 01/08 a 31/08 aparece ao filtrar 01/08–26/08; OU
|
||||
# • tiveram algum PAGAMENTO dentro do período (pagar em agosto um fechamento
|
||||
# de julho tem que aparecer em agosto).
|
||||
#
|
||||
# STATUS: por padrão só as FINALIZADAS — é o "Consolidado" do dashboard, o valor
|
||||
# que já fechou. 'todos' inclui os rascunhos em andamento (a visão "Geral"), que
|
||||
# ainda mudam de valor.
|
||||
#
|
||||
# Só motoristas ATIVOS (não arquivados) contam: é o mesmo conjunto que a
|
||||
# consolidação paga (ConsolidacaoMotorista.ativos), então o total desta tela
|
||||
# bate com a soma dos fechamentos.
|
||||
module Analytics
|
||||
class TotaisPorMotorista
|
||||
# status: 'finalizada' (padrão — só o que já fechou), 'rascunho' ou
|
||||
# 'todos'. É o MESMO select de status da lista de consolidações.
|
||||
# motorista: recorte exato (drill-down / painel do motorista).
|
||||
# busca: filtro parcial por nome, o campo de busca da lista.
|
||||
def initialize(inicio:, fim:, motorista: nil, status: 'finalizada', busca: nil)
|
||||
@inicio = inicio.to_date
|
||||
@fim = fim.to_date
|
||||
@motorista = motorista.presence
|
||||
@status = status.presence || 'finalizada'
|
||||
@busca = busca.presence
|
||||
end
|
||||
|
||||
attr_reader :inicio, :fim, :motorista, :status
|
||||
|
||||
# ConsolidacaoMotorista do recorte, com a consolidação já carregada (as duas
|
||||
# telas mostram nome/período/status dela em seguida).
|
||||
def registros
|
||||
@registros ||= begin
|
||||
base = Consolidacao.ativas
|
||||
# `public_send` sobre o enum: quem traduz :finalizada/:rascunho é o model.
|
||||
base = base.public_send(@status) if %w[finalizada rascunho].include?(@status)
|
||||
|
||||
escopo = ConsolidacaoMotorista.ativos
|
||||
.where(consolidacao_id: ids_do_periodo(base))
|
||||
.includes(:consolidacao)
|
||||
escopo = escopo.where(motorista_nome: @motorista) if @motorista
|
||||
escopo = escopo.where('motorista_nome ILIKE ?', "%#{@busca}%") if @busca
|
||||
escopo.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Uma linha por motorista, do maior total para o menor.
|
||||
def linhas
|
||||
@linhas ||= registros.group_by(&:motorista_nome).map do |nome, cms|
|
||||
pagos = cms.select(&:pago?)
|
||||
pendentes = cms.reject(&:pago?)
|
||||
{
|
||||
motorista: nome,
|
||||
consolidacoes: cms.size,
|
||||
lancamentos: lancamentos_por_motorista[nome].to_i,
|
||||
valor_total: soma(cms),
|
||||
valor_pago: soma(pagos),
|
||||
valor_pendente: soma(pendentes),
|
||||
pagas: pagos.size,
|
||||
pendentes: pendentes.size
|
||||
}
|
||||
end.sort_by { |l| -l[:valor_total] }
|
||||
end
|
||||
|
||||
# Consolidações que compõem o total de UM motorista (o drill-down), da mais
|
||||
# recente para a mais antiga.
|
||||
def consolidacoes_de(nome)
|
||||
registros.select { |cm| cm.motorista_nome == nome }
|
||||
.sort_by { |cm| [cm.consolidacao.data_fim, cm.consolidacao.data_inicio] }
|
||||
.reverse
|
||||
end
|
||||
|
||||
def motoristas
|
||||
linhas.map { |l| l[:motorista] }
|
||||
end
|
||||
|
||||
def total_geral = soma(registros)
|
||||
def total_pago = soma(registros.select(&:pago?))
|
||||
def total_pendente = soma(registros.reject(&:pago?))
|
||||
def total_consolidacoes = registros.map(&:consolidacao_id).uniq.size
|
||||
def vazio? = registros.empty?
|
||||
|
||||
def periodo_label
|
||||
"#{@inicio.strftime('%d/%m/%Y')} a #{@fim.strftime('%d/%m/%Y')}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# As consolidações do período: as que cruzam a faixa + as que receberam
|
||||
# pagamento dentro dela. Mesmo par de conjuntos do dashboard financeiro.
|
||||
def ids_do_periodo(base)
|
||||
intervalo = @inicio.beginning_of_day..@fim.end_of_day
|
||||
|
||||
cruzam = base.cruzando_periodo(@inicio, @fim).ids
|
||||
pagas = ConsolidacaoMotorista.ativos
|
||||
.where(pago_em: intervalo)
|
||||
.where(consolidacao_id: base.select(:id))
|
||||
.distinct.pluck(:consolidacao_id)
|
||||
(cruzam + pagas).uniq
|
||||
end
|
||||
|
||||
def soma(cms)
|
||||
cms.sum { |cm| cm.valor_total || 0 }
|
||||
end
|
||||
|
||||
# Lançamentos classificados por motorista (soma da quantidade — um termo com
|
||||
# quantidade 5 vale 5). Uma query só para todas as consolidações do recorte,
|
||||
# em vez de uma por linha da tabela.
|
||||
def lancamentos_por_motorista
|
||||
@lancamentos_por_motorista ||= begin
|
||||
ids = registros.map(&:consolidacao_id).uniq
|
||||
if ids.empty?
|
||||
{}
|
||||
else
|
||||
ConsolidacaoEntrega.where(consolidacao_id: ids,
|
||||
motorista_nome: registros.map(&:motorista_nome).uniq)
|
||||
.group(:motorista_nome)
|
||||
.sum(:quantidade)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,65 +0,0 @@
|
||||
# app/services/notificacao/blocos.rb
|
||||
#
|
||||
# Catálogo dos blocos que o ADM pode empilhar no editor. É a ÚNICA fonte da
|
||||
# verdade sobre quais tipos existem e quais campos cada um tem — o editor monta
|
||||
# a paleta a partir daqui, o model valida a partir daqui e o renderizador
|
||||
# desenha a partir daqui.
|
||||
#
|
||||
# Bloco desconhecido é DESCARTADO na gravação (ver MensagemTemplate): sem isso,
|
||||
# um JSON adulterado no form viraria conteúdo renderizado sem passar por
|
||||
# validação nenhuma.
|
||||
module Notificacao
|
||||
module Blocos
|
||||
# tipo => { rotulo:, campos: { chave => tipo_do_campo } }
|
||||
# tipo_do_campo: :texto (uma linha) | :multilinha | :lista (tabela)
|
||||
CATALOGO = {
|
||||
'cabecalho' => {
|
||||
rotulo: 'Cabeçalho',
|
||||
dica: 'Título da mensagem, em destaque.',
|
||||
campos: { 'titulo' => :texto, 'subtitulo' => :texto }
|
||||
},
|
||||
'texto' => {
|
||||
rotulo: 'Texto',
|
||||
dica: 'Parágrafo livre. Use as variáveis para personalizar.',
|
||||
campos: { 'texto' => :multilinha }
|
||||
},
|
||||
'tabela' => {
|
||||
rotulo: 'Tabela de valores',
|
||||
dica: 'Pares rótulo/valor — entregas, valor, período…',
|
||||
campos: { 'linhas' => :lista }
|
||||
},
|
||||
'aviso' => {
|
||||
rotulo: 'Aviso de mudança',
|
||||
dica: 'Destaque em amarelo. Bom para "o que foi alterado".',
|
||||
campos: { 'texto' => :multilinha }
|
||||
},
|
||||
'botao' => {
|
||||
rotulo: 'Botão / link',
|
||||
dica: 'No e-mail vira botão; no WhatsApp, o link em texto.',
|
||||
campos: { 'rotulo' => :texto, 'url' => :texto }
|
||||
},
|
||||
'divisor' => {
|
||||
rotulo: 'Divisor',
|
||||
dica: 'Linha separando seções.',
|
||||
campos: {}
|
||||
},
|
||||
'rodape' => {
|
||||
rotulo: 'Rodapé',
|
||||
dica: 'Texto pequeno no fim (assinatura, aviso legal).',
|
||||
campos: { 'texto' => :multilinha }
|
||||
}
|
||||
}.freeze
|
||||
|
||||
TIPOS = CATALOGO.keys.freeze
|
||||
|
||||
def self.valido?(tipo) = TIPOS.include?(tipo.to_s)
|
||||
def self.campos(tipo) = CATALOGO.dig(tipo.to_s, :campos) || {}
|
||||
def self.rotulo(tipo) = CATALOGO.dig(tipo.to_s, :rotulo) || tipo.to_s
|
||||
|
||||
# Bloco novo com os campos vazios — usado pelo editor ao arrastar da paleta.
|
||||
def self.vazio(tipo)
|
||||
campos(tipo).to_h { |chave, especie| [chave, especie == :lista ? [] : ''] }
|
||||
.merge('tipo' => tipo.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,196 +0,0 @@
|
||||
# app/services/notificacao/catalogo_variaveis.rb
|
||||
#
|
||||
# TODAS as variáveis que o editor de mensagem oferece, de quatro origens:
|
||||
#
|
||||
# :contexto — vêm com o disparo (motorista, consolidação, valor…). Só têm
|
||||
# valor nos gatilhos que as produzem; em outro gatilho saem
|
||||
# vazias, e a tela avisa isso.
|
||||
# :sistema — não dependem de contexto nenhum (empresa, data, hora, mês).
|
||||
# :dados — CONSULTAM O BANCO na hora do envio (entregas do mês, total a
|
||||
# pagar…). Resolvidas sob demanda: só a variável realmente
|
||||
# escrita no template vira query.
|
||||
# :personalizada — cadastradas pelo ADM (VariavelPersonalizada), valor fixo.
|
||||
#
|
||||
# Antes, `Notificacao::Variaveis::POR_GATILHO` amarrava a lista ao gatilho: o ADM
|
||||
# via 6 variáveis e nada mais, e qualquer texto com número (“entregas do mês”)
|
||||
# precisava de deploy. Aqui a lista é aberta — e o que não se aplica é MARCADO em
|
||||
# vez de escondido.
|
||||
#
|
||||
# ⚠️ Um `resolver` NUNCA pode levantar: notificação não derruba fechamento. O
|
||||
# ResolvedorVariaveis engole e devolve vazio.
|
||||
module Notificacao
|
||||
module CatalogoVariaveis
|
||||
ORIGENS = {
|
||||
contexto: 'Do que aconteceu',
|
||||
sistema: 'Empresa e data',
|
||||
dados: 'Números do sistema',
|
||||
personalizada: 'Suas variáveis'
|
||||
}.freeze
|
||||
|
||||
# ── Sistema: sempre disponíveis ───────────────────────────
|
||||
SISTEMA = {
|
||||
'empresa' => {
|
||||
descricao: 'Nome da empresa', amostra: 'Reem Transportes',
|
||||
resolver: -> { Configuracao.valor('empresa_nome').presence || 'Reem Transportes' }
|
||||
},
|
||||
'data' => {
|
||||
descricao: 'Data de hoje (dd/mm/aaaa)', amostra: '26/08/2026',
|
||||
resolver: -> { Date.current.strftime('%d/%m/%Y') }
|
||||
},
|
||||
'hora' => {
|
||||
descricao: 'Hora do envio', amostra: '14:35',
|
||||
resolver: -> { Time.current.strftime('%H:%M') }
|
||||
},
|
||||
'dia_semana' => {
|
||||
descricao: 'Dia da semana por extenso', amostra: 'quarta-feira',
|
||||
resolver: -> { I18n.l(Date.current, format: '%A') rescue Date::DAYNAMES[Date.current.wday] }
|
||||
},
|
||||
'mes' => {
|
||||
descricao: 'Mês por extenso', amostra: 'agosto',
|
||||
resolver: -> { I18n.l(Date.current, format: '%B') rescue Date::MONTHNAMES[Date.current.month] }
|
||||
},
|
||||
'mes_ano' => {
|
||||
descricao: 'Mês/ano (mm/aaaa)', amostra: '08/2026',
|
||||
resolver: -> { Date.current.strftime('%m/%Y') }
|
||||
},
|
||||
'link_sistema' => {
|
||||
descricao: 'Endereço do sistema', amostra: 'https://app.reemtransportes.com.br',
|
||||
resolver: -> { Configuracao.valor('url_sistema').presence || ENV.fetch('APP_URL', '') }
|
||||
}
|
||||
}.freeze
|
||||
|
||||
# ── Contexto: quem produz é o gatilho ─────────────────────
|
||||
# `gatilhos:` diz em quais a variável tem valor — é o que a tela usa para
|
||||
# avisar "esta sai vazia aqui".
|
||||
CONTEXTO = {
|
||||
'contato' => { descricao: 'Primeiro nome de quem recebe', amostra: 'Carlos', gatilhos: :todos },
|
||||
'quem' => { descricao: 'Quem executou a ação no sistema', amostra: 'Ana Paula', gatilhos: :todos },
|
||||
'motorista' => { descricao: 'Nome do motorista', amostra: 'Carlos Matheus Pimentel' },
|
||||
'consolidacao' => { descricao: 'Nome da consolidação', amostra: 'UBS NORTE 01–14/08' },
|
||||
'periodo' => { descricao: 'Período do fechamento', amostra: '01/08/2026 a 14/08/2026' },
|
||||
'valor' => { descricao: 'Valor do motorista', amostra: 'R$ 5.060,00' },
|
||||
'entregas' => { descricao: 'Entregas atendidas no fechamento', amostra: '280' },
|
||||
'link_painel' => { descricao: 'Link do painel do motorista', amostra: 'https://app.reem.com.br/motorista' },
|
||||
'o_que_mudou' => { descricao: 'O que foi alterado', amostra: 'Desconto de R$ 120,00 (avaria)' },
|
||||
'operacao' => { descricao: 'Operação afetada', amostra: 'UBS NORTE AGO 2026' },
|
||||
'nf' => { descricao: 'Nota fiscal', amostra: '85382' },
|
||||
'entregues' => { descricao: 'Notas entregues (resumo)', amostra: '4.851' },
|
||||
'recusas' => { descricao: 'Notas não entregues (resumo)', amostra: '122' },
|
||||
'pendentes' => { descricao: 'Notas em aberto (resumo)', amostra: '0' },
|
||||
'retentativas' => { descricao: 'Visitas além da primeira (resumo)', amostra: '4' },
|
||||
'fora_operacao' => { descricao: 'Notas fora da operação (resumo)', amostra: '2' }
|
||||
}.freeze
|
||||
|
||||
# Quais variáveis de contexto cada gatilho realmente preenche.
|
||||
POR_GATILHO = {
|
||||
'manual' => [],
|
||||
'consolidacao_finalizada' => %w[motorista consolidacao periodo valor entregas link_painel],
|
||||
'pagamento_efetuado' => %w[motorista consolidacao periodo valor entregas link_painel],
|
||||
'valor_alterado' => %w[motorista consolidacao periodo valor entregas link_painel o_que_mudou],
|
||||
'operacao_alterada' => %w[operacao o_que_mudou nf],
|
||||
'agendado' => %w[operacao periodo entregues recusas pendentes retentativas fora_operacao]
|
||||
}.freeze
|
||||
|
||||
# ── Dados: consultam o banco na hora do envio ─────────────
|
||||
# Cada uma responde a uma pergunta que o ADM hoje só conseguiria escrevendo à
|
||||
# mão — e que ficaria desatualizada na mensagem seguinte.
|
||||
DADOS = {
|
||||
'entregas_mes' => {
|
||||
descricao: 'Entregas atendidas no mês corrente', amostra: '4.977',
|
||||
resolver: -> { Entrega.atendidas.da_conta_gade.no_periodo_checkout(Date.current.beginning_of_month, Date.current).count }
|
||||
},
|
||||
'entregas_hoje' => {
|
||||
descricao: 'Entregas atendidas hoje', amostra: '213',
|
||||
resolver: -> { Entrega.atendidas.da_conta_gade.no_periodo_checkout(Date.current, Date.current).count }
|
||||
},
|
||||
'motoristas_ativos_mes' => {
|
||||
descricao: 'Motoristas com entrega no mês', amostra: '17',
|
||||
resolver: -> { Entrega.motoristas_ativos(inicio: Date.current.beginning_of_month, fim: Date.current).size }
|
||||
},
|
||||
'consolidacoes_abertas' => {
|
||||
descricao: 'Consolidações em rascunho', amostra: '3',
|
||||
resolver: -> { Consolidacao.ativas.rascunho.count }
|
||||
},
|
||||
'total_a_pagar_mes' => {
|
||||
descricao: 'Total a pagar no mês (fechado e não pago)', amostra: 'R$ 72.508,00',
|
||||
resolver: lambda {
|
||||
totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month,
|
||||
fim: Date.current.end_of_month)
|
||||
Formatador.moeda(totais.total_pendente)
|
||||
}
|
||||
},
|
||||
'total_pago_mes' => {
|
||||
descricao: 'Total já pago no mês', amostra: 'R$ 21.300,00',
|
||||
resolver: lambda {
|
||||
totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month,
|
||||
fim: Date.current.end_of_month)
|
||||
Formatador.moeda(totais.total_pago)
|
||||
}
|
||||
},
|
||||
'total_consolidado_mes' => {
|
||||
descricao: 'Total consolidado no mês', amostra: 'R$ 93.808,00',
|
||||
resolver: lambda {
|
||||
totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month,
|
||||
fim: Date.current.end_of_month)
|
||||
Formatador.moeda(totais.total_geral)
|
||||
}
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
# Todas as variáveis conhecidas, incluindo as que o ADM cadastrou.
|
||||
# { chave => { origem:, descricao:, amostra: } }
|
||||
def todas
|
||||
base = {}
|
||||
SISTEMA.each { |c, m| base[c] = { origem: :sistema, descricao: m[:descricao], amostra: m[:amostra] } }
|
||||
CONTEXTO.each { |c, m| base[c] = { origem: :contexto, descricao: m[:descricao], amostra: m[:amostra] } }
|
||||
DADOS.each { |c, m| base[c] = { origem: :dados, descricao: m[:descricao], amostra: m[:amostra] } }
|
||||
personalizadas.each do |v|
|
||||
base[v.chave] = { origem: :personalizada, descricao: v.descricao.presence || 'Sua variável', amostra: v.valor }
|
||||
end
|
||||
base
|
||||
end
|
||||
|
||||
# A variável tem valor NESTE gatilho? Contexto de outro gatilho sai vazio —
|
||||
# a tela mostra, em vez de esconder a variável (foi o pedido: liberdade com
|
||||
# aviso, não lista curta).
|
||||
def aplicavel?(chave, gatilho)
|
||||
meta = CONTEXTO[chave.to_s]
|
||||
return true if meta.nil? # sistema/dados/personalizada: sempre
|
||||
return true if meta[:gatilhos] == :todos
|
||||
|
||||
Array(POR_GATILHO[gatilho.to_s]).include?(chave.to_s)
|
||||
end
|
||||
|
||||
def origem(chave)
|
||||
todas.dig(chave.to_s, :origem)
|
||||
end
|
||||
|
||||
def existe?(chave) = todas.key?(chave.to_s)
|
||||
|
||||
# Agrupadas para a tela do editor, na ordem de ORIGENS.
|
||||
def por_origem(gatilho = nil)
|
||||
agrupadas = ORIGENS.keys.index_with { |o| [] }
|
||||
todas.each do |chave, meta|
|
||||
agrupadas[meta[:origem]] << meta.merge(chave: chave, aplicavel: gatilho.nil? || aplicavel?(chave, gatilho))
|
||||
end
|
||||
agrupadas
|
||||
end
|
||||
|
||||
# Contexto do PREVIEW: valores de amostra, nunca de envio real.
|
||||
def amostra(gatilho = nil)
|
||||
todas.filter_map { |chave, meta|
|
||||
[chave, meta[:amostra].to_s] if gatilho.nil? || aplicavel?(chave, gatilho)
|
||||
}.to_h
|
||||
end
|
||||
|
||||
def personalizadas
|
||||
return [] unless VariavelPersonalizada.table_exists?
|
||||
|
||||
VariavelPersonalizada.ativas.to_a
|
||||
rescue StandardError
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,110 +0,0 @@
|
||||
# app/services/notificacao/cliente_whatsapp.rb
|
||||
#
|
||||
# Cliente da ponte Baileys (container `whatsapp`). Substitui o Twilio como canal
|
||||
# padrão de WhatsApp.
|
||||
#
|
||||
# ⚠️ Canal NÃO OFICIAL: a ponte fala o protocolo do WhatsApp Web por engenharia
|
||||
# reversa. É gratuito e ilimitado, mas está fora dos Termos do WhatsApp e a
|
||||
# Meta pode banir o número. Por isso: chip dedicado, e o intervalo entre
|
||||
# mensagens é respeitado por quem chama (Notificacao::Despachante), não aqui.
|
||||
#
|
||||
# TIMEOUT em tudo: sem ele, uma ponte pendurada trava o worker Puma até o proxy
|
||||
# cortar — o mesmo motivo que levou o ClienteTwilio a configurar timeout.
|
||||
module Notificacao
|
||||
class ClienteWhatsapp
|
||||
TIMEOUT_ABERTURA = 5
|
||||
TIMEOUT_LEITURA = 20
|
||||
|
||||
Resposta = Struct.new(:ok, :dados, :erro, keyword_init: true) do
|
||||
def ok? = ok
|
||||
end
|
||||
|
||||
def self.padrao
|
||||
config = ConfiguracaoNotificacao.instancia
|
||||
new(url: config.baileys_url_efetiva, token: config.baileys_token_efetivo)
|
||||
end
|
||||
|
||||
def initialize(url:, token:)
|
||||
@url = url.to_s.strip.chomp('/')
|
||||
@token = token.to_s
|
||||
end
|
||||
|
||||
def configurado?
|
||||
@url.present? && @token.present?
|
||||
end
|
||||
|
||||
# { conectado:, numero:, qr:, desde:, ultimo_erro: }
|
||||
def status
|
||||
get('/status')
|
||||
end
|
||||
|
||||
def enviar(para:, texto:)
|
||||
post('/enviar', para: para, texto: texto)
|
||||
end
|
||||
|
||||
# Grupos de que o número conectado participa. O id do grupo é opaco, então a
|
||||
# tela precisa desta lista para o ADM escolher pelo nome.
|
||||
def grupos
|
||||
get('/grupos')
|
||||
end
|
||||
|
||||
def desconectar
|
||||
post('/logout')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get(caminho)
|
||||
requisitar(Net::HTTP::Get, caminho)
|
||||
end
|
||||
|
||||
def post(caminho, **corpo)
|
||||
requisitar(Net::HTTP::Post, caminho, corpo)
|
||||
end
|
||||
|
||||
def requisitar(classe, caminho, corpo = nil)
|
||||
return Resposta.new(ok: false, erro: 'Ponte do WhatsApp não configurada.') unless configurado?
|
||||
|
||||
uri = URI.join("#{@url}/", caminho.delete_prefix('/'))
|
||||
req = classe.new(uri)
|
||||
req['Authorization'] = "Bearer #{@token}"
|
||||
req['Content-Type'] = 'application/json'
|
||||
req.body = corpo.to_json if corpo
|
||||
|
||||
resposta = Net::HTTP.start(uri.hostname, uri.port,
|
||||
use_ssl: uri.scheme == 'https',
|
||||
open_timeout: TIMEOUT_ABERTURA,
|
||||
read_timeout: TIMEOUT_LEITURA) { |http| http.request(req) }
|
||||
|
||||
dados = parse(resposta.body)
|
||||
if resposta.is_a?(Net::HTTPSuccess)
|
||||
Resposta.new(ok: true, dados: dados)
|
||||
else
|
||||
Resposta.new(ok: false, dados: dados, erro: mensagem_de_erro(resposta, dados))
|
||||
end
|
||||
rescue StandardError => e
|
||||
# A ponte fora do ar não pode virar 500 na tela nem quebrar um fechamento.
|
||||
Rails.logger.error("[ClienteWhatsapp] #{e.class}: #{e.message}")
|
||||
Resposta.new(ok: false, erro: "#{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
def parse(corpo)
|
||||
JSON.parse(corpo.to_s)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def mensagem_de_erro(resposta, dados)
|
||||
return 'Token da ponte do WhatsApp inválido.' if resposta.code == '401'
|
||||
return 'WhatsApp desconectado — leia o QR code na tela.' if resposta.code == '503'
|
||||
# 404 = a ponte respondeu, mas não conhece a rota: a imagem do container
|
||||
# `whatsapp` é anterior a ela. "rota desconhecida" cru na tela não diz o
|
||||
# que fazer; o conserto é rebuildar a ponte, não mexer no Rails.
|
||||
if resposta.code == '404'
|
||||
return 'Ponte do WhatsApp desatualizada (não conhece esta rota) — refaça o build do container `whatsapp`.'
|
||||
end
|
||||
|
||||
dados['erro'].presence || "HTTP #{resposta.code}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,195 +0,0 @@
|
||||
# app/services/notificacao/despachante.rb
|
||||
#
|
||||
# Entrega UMA mensagem já montada aos destinatários de um evento.
|
||||
#
|
||||
# Separação proposital: o Despachante resolve QUEM recebe, por qual canal,
|
||||
# respeita o intervalo entre envios e registra o resultado. O TEXTO vem do
|
||||
# template de blocos do evento (MensagemTemplate), renderizado por canal — HTML
|
||||
# no e-mail, texto puro no WhatsApp, do mesmo template.
|
||||
#
|
||||
# `corpo:`/`assunto:` são o FALLBACK: valem quando o evento ainda não tem
|
||||
# template montado (o texto padrão do código) ou quando a mensagem é digitada na
|
||||
# hora (disparo manual). Sem esse fallback, ligar o editor apagaria as
|
||||
# notificações dos eventos que já funcionavam.
|
||||
#
|
||||
# Destinatários = contatos dos grupos que assinam o evento
|
||||
# + (opcional) a pessoa diretamente envolvida no fato, que é o
|
||||
# comportamento que já existia: o motorista dono do pagamento.
|
||||
#
|
||||
# ⚠️ INTERVALO: no WhatsApp por sessão QR (canal não oficial), disparar em
|
||||
# rajada para dezenas de números é a forma mais rápida de perder o número.
|
||||
# Por isso o envio roda em job, com pausa configurável entre mensagens —
|
||||
# nunca dentro da requisição web.
|
||||
module Notificacao
|
||||
class Despachante
|
||||
# Dispara em background. Nunca levanta: uma notificação não pode derrubar o
|
||||
# fechamento de um pagamento (mesma regra do NotificacaoService).
|
||||
# Dispara UM evento específico (disparo manual pelo botão da tela).
|
||||
def self.disparar(chave:, dados: {}, assunto: nil, corpo: nil, envolvido: nil)
|
||||
evento = EventoNotificacao.ativos.find_by(chave: chave.to_s)
|
||||
return if evento.nil?
|
||||
|
||||
agendar(evento, dados: dados, assunto: assunto, corpo: corpo, envolvido: envolvido)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Despachante] falha ao agendar #{chave}: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
# Dispara TODOS os eventos ativos de um gatilho.
|
||||
#
|
||||
# É o que o código de negócio usa. Buscar pela `chave` (como era antes) fazia
|
||||
# só o evento de SISTEMA disparar: um evento criado pelo ADM com o mesmo
|
||||
# gatilho ficava mudo para sempre, sem erro nenhum — a tela oferecia o
|
||||
# gatilho e nada acontecia. Com N eventos no mesmo gatilho, cada um tem seus
|
||||
# próprios grupos e sua própria mensagem, que é o ponto de poder cadastrar.
|
||||
def self.disparar_gatilho(gatilho:, dados: {}, assunto: nil, corpo: nil, envolvido: nil)
|
||||
EventoNotificacao.ativos.where(gatilho: gatilho.to_s).find_each do |evento|
|
||||
agendar(evento, dados: dados, assunto: assunto, corpo: corpo, envolvido: envolvido)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Despachante] falha ao agendar gatilho #{gatilho}: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
def self.agendar(evento, dados:, assunto:, corpo:, envolvido:)
|
||||
NotificacaoJob.perform_later(evento.id, (dados || {}).stringify_keys,
|
||||
assunto.to_s, corpo.to_s, envolvido&.id)
|
||||
end
|
||||
private_class_method :agendar
|
||||
|
||||
def initialize(evento, dados: {}, assunto: nil, corpo: nil, envolvido: nil)
|
||||
@evento = evento
|
||||
@dados = (dados || {}).stringify_keys
|
||||
@assunto = assunto.presence || evento.nome
|
||||
@corpo = corpo.to_s
|
||||
@envolvido = envolvido
|
||||
@config = ConfiguracaoNotificacao.instancia
|
||||
end
|
||||
|
||||
def executar
|
||||
enviar_whatsapp if @config.whatsapp_habilitado?
|
||||
enviar_email if @config.email_habilitado?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enviar_whatsapp
|
||||
intervalo = @config.intervalo_envio
|
||||
|
||||
destinos_whatsapp.each_with_index do |(destino, contato, user), i|
|
||||
# Pausa ENTRE mensagens, não antes da primeira.
|
||||
sleep(intervalo) if i.positive? && intervalo.positive?
|
||||
|
||||
corpo = corpo_whatsapp(contato, user)
|
||||
envio = registrar(canal: 'whatsapp', destino: destino, contato: contato,
|
||||
user: user, corpo: corpo)
|
||||
resposta = Whatsapp.enviar(para: destino, texto: corpo, config: @config)
|
||||
resposta.ok? ? envio.marcar_enviado! : envio.marcar_falha!(resposta.erro)
|
||||
end
|
||||
end
|
||||
|
||||
# ── Corpo por canal ─────────────────────────────────────────
|
||||
# A renderização acontece POR DESTINATÁRIO, não uma vez só: {{contato}} é o
|
||||
# primeiro nome de quem recebe, então um render compartilhado mandaria o
|
||||
# nome da primeira pessoa para todo mundo. Renderizar é manipulação de
|
||||
# string — o custo por destinatário é irrelevante perto do envio em si.
|
||||
def dados_para(contato, user)
|
||||
nome = contato&.primeiro_nome.presence || user&.nome.to_s.split.first
|
||||
dados_completos.merge('contato' => nome.to_s)
|
||||
end
|
||||
|
||||
# O contexto do gatilho + as variáveis que o TEMPLATE usa e o gatilho não
|
||||
# conhece ({{entregas_mes}}, {{telefone_suporte}}, {{hora}}…).
|
||||
#
|
||||
# Resolvido UMA vez por disparo, não por destinatário: `{{entregas_mes}}` é
|
||||
# uma query, e o valor é o mesmo para todo mundo — só `{{contato}}` muda.
|
||||
def dados_completos
|
||||
@dados_completos ||= ResolvedorVariaveis.completar(textos_do_disparo, @dados)
|
||||
end
|
||||
|
||||
# Tudo que será interpolado: os blocos dos dois templates (o evento pode ter
|
||||
# WhatsApp e e-mail com textos diferentes), o assunto e o corpo padrão.
|
||||
def textos_do_disparo
|
||||
[@assunto, @corpo,
|
||||
@evento.template_utilizavel('whatsapp')&.blocos,
|
||||
@evento.template_utilizavel('email')&.blocos,
|
||||
@evento.template_utilizavel('email')&.assunto]
|
||||
end
|
||||
|
||||
# O template do editor vence; sem template utilizável, cai no texto que o
|
||||
# chamador passou (texto padrão do código ou mensagem digitada na hora).
|
||||
def corpo_whatsapp(contato, user)
|
||||
template = @evento.template_utilizavel('whatsapp')
|
||||
return @corpo unless template
|
||||
|
||||
template.renderizador(dados_para(contato, user)).texto.presence || @corpo
|
||||
end
|
||||
|
||||
# [assunto, corpo, html?] — com template o e-mail sai em HTML montado; sem
|
||||
# template, texto puro que o mailer formata.
|
||||
def email_montado(contato, user)
|
||||
template = @evento.template_utilizavel('email')
|
||||
return [@assunto, @corpo, false] unless template
|
||||
|
||||
r = template.renderizador(dados_para(contato, user))
|
||||
[r.interpolar(template.assunto).presence || @assunto, r.html, true]
|
||||
end
|
||||
|
||||
def enviar_email
|
||||
destinos_email.each do |destino, contato, user|
|
||||
assunto, corpo, html = email_montado(contato, user)
|
||||
envio = registrar(canal: 'email', destino: destino, contato: contato,
|
||||
user: user, corpo: corpo, assunto: assunto)
|
||||
begin
|
||||
NotificacaoMailer.mensagem(destino, assunto, corpo, html: html).deliver_now
|
||||
envio.marcar_enviado!
|
||||
rescue StandardError => e
|
||||
envio.marcar_falha!("#{e.class}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# [[destino, contato, user], ...] sem repetir o mesmo número/e-mail — a mesma
|
||||
# pessoa pode ser contato de um grupo E o envolvido no fato.
|
||||
def destinos_whatsapp
|
||||
# `destino_whatsapp` resolve pessoa (número) ou grupo (JID) — ver Contato.
|
||||
lista = @evento.destinatarios('whatsapp').map { |c| [c.destino_whatsapp, c, c.user] }
|
||||
lista += envolvido_whatsapp
|
||||
dedup(lista)
|
||||
end
|
||||
|
||||
def destinos_email
|
||||
lista = @evento.destinatarios('email').map { |c| [c.email, c, c.user] }
|
||||
lista += envolvido_email
|
||||
dedup(lista)
|
||||
end
|
||||
|
||||
def envolvido_whatsapp
|
||||
return [] unless notificar_envolvido?
|
||||
|
||||
numero = ConfiguracaoNotificacao.normalizar_telefone(@envolvido.telefone)
|
||||
numero.present? ? [[numero, nil, @envolvido]] : []
|
||||
end
|
||||
|
||||
def envolvido_email
|
||||
return [] unless notificar_envolvido? && @envolvido.email.present?
|
||||
|
||||
[[@envolvido.email, nil, @envolvido]]
|
||||
end
|
||||
|
||||
def notificar_envolvido?
|
||||
@envolvido.present? && @evento.notificar_envolvido?
|
||||
end
|
||||
|
||||
def dedup(lista)
|
||||
lista.reject { |destino, _, _| destino.blank? }
|
||||
.uniq { |destino, _, _| destino.to_s.downcase }
|
||||
end
|
||||
|
||||
def registrar(canal:, destino:, contato:, user:, corpo:, assunto: nil)
|
||||
NotificacaoEnvio.create!(
|
||||
evento_notificacao: @evento, contato: contato, user: user,
|
||||
canal: canal, destino: destino, assunto: assunto,
|
||||
corpo: corpo, status: 'pendente'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,31 +0,0 @@
|
||||
# app/services/notificacao/formatador.rb
|
||||
#
|
||||
# Formatação dos valores que entram numa mensagem. Existe porque o mesmo `moeda`
|
||||
# estava copiado em `Notificacao::Gatilhos` e em `NotificacaoService` — e agora o
|
||||
# catálogo de variáveis também precisa dele, o que faria três cópias.
|
||||
#
|
||||
# Métodos de módulo (sem estado): é chamado de model, job, service e view.
|
||||
module Notificacao
|
||||
module Formatador
|
||||
module_function
|
||||
|
||||
# R$ 1.234,56 — separador de milhar incluído (o `number_to_currency` do Rails
|
||||
# depende de locale carregado, e isto roda também dentro de job).
|
||||
def moeda(valor)
|
||||
inteiro, decimais = format('%.2f', valor.to_f).split('.')
|
||||
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||
"R$ #{inteiro},#{decimais}"
|
||||
end
|
||||
|
||||
def periodo(inicio, fim)
|
||||
return '' if inicio.blank? || fim.blank?
|
||||
|
||||
"#{inicio.to_date.strftime('%d/%m/%Y')} a #{fim.to_date.strftime('%d/%m/%Y')}"
|
||||
end
|
||||
|
||||
# Números grandes ficam ilegíveis sem separador numa mensagem de WhatsApp.
|
||||
def numero(valor)
|
||||
valor.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,128 +0,0 @@
|
||||
# app/services/notificacao/gatilhos.rb
|
||||
#
|
||||
# Os pontos do sistema que DISPARAM um evento de notificação. Cada método monta
|
||||
# o contexto das {{variaveis}} daquele gatilho e entrega ao Despachante.
|
||||
#
|
||||
# Existe para que o código de negócio (consolidação, edição de lançamento, jobs)
|
||||
# tenha UMA linha de chamada, sem saber de template, canal ou destinatário — e
|
||||
# para que a lista de variáveis que o editor oferece (Notificacao::Variaveis)
|
||||
# tenha um par exato aqui. Variável anunciada na tela e nunca preenchida sai como
|
||||
# vazio na mensagem, o que é pior do que não existir.
|
||||
#
|
||||
# NADA aqui levanta: uma notificação não pode derrubar um fechamento nem uma
|
||||
# edição de lançamento. O Despachante já engole, e os métodos que montam o
|
||||
# contexto também.
|
||||
module Notificacao
|
||||
module Gatilhos
|
||||
module_function
|
||||
|
||||
# Valor do motorista mudou DEPOIS do fechamento (lançamento editado,
|
||||
# desconto aplicado, pilar remarcado). Em rascunho o valor muda a cada
|
||||
# clique do wizard — avisar ali seria spam, não informação.
|
||||
def valor_alterado(consolidacao, consolidacao_motorista, anterior:)
|
||||
return unless consolidacao.finalizada?
|
||||
|
||||
atual = consolidacao_motorista.valor_total.to_f
|
||||
return if anterior.to_f == atual
|
||||
|
||||
Despachante.disparar_gatilho(
|
||||
gatilho: 'valor_alterado',
|
||||
dados: Variaveis.comuns_reais.merge(
|
||||
'motorista' => consolidacao_motorista.motorista_nome,
|
||||
'consolidacao' => consolidacao.nome,
|
||||
'periodo' => periodo(consolidacao.data_inicio, consolidacao.data_fim),
|
||||
'valor' => moeda(atual),
|
||||
'entregas' => contar_entregas(consolidacao, consolidacao_motorista).to_s,
|
||||
'link_painel' => link_painel,
|
||||
'o_que_mudou' => "Valor passou de #{moeda(anterior)} para #{moeda(atual)}"
|
||||
),
|
||||
assunto: "Valor alterado — #{consolidacao_motorista.motorista_nome}",
|
||||
corpo: "⚠️ O valor de #{consolidacao_motorista.motorista_nome} em #{consolidacao.nome} " \
|
||||
"passou de #{moeda(anterior)} para #{moeda(atual)}.",
|
||||
# O próprio motorista é parte interessada: o valor DELE mudou depois de
|
||||
# fechado. Fica sob o `notificar_envolvido` do evento, então o ADM pode
|
||||
# desligar sem perder o aviso à diretoria.
|
||||
envolvido: motorista_usuario(consolidacao_motorista)
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Gatilhos] valor_alterado: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
# Dados da operação mudaram. Duas origens, os dois caminham para cá:
|
||||
# - edição de lançamento no SimpliRoute feita por dentro do sistema;
|
||||
# - varredura periódica que compara o espelho com o retrato anterior
|
||||
# (DetectarMudancasOperacaoJob) — é por onde chega a maioria, já que o
|
||||
# espelho sincroniza mudanças feitas fora daqui.
|
||||
def operacao_alterada(operacao:, o_que_mudou:, nf: nil)
|
||||
Despachante.disparar_gatilho(
|
||||
gatilho: 'operacao_alterada',
|
||||
dados: Variaveis.comuns_reais.merge(
|
||||
'operacao' => operacao.to_s,
|
||||
'o_que_mudou' => o_que_mudou.to_s,
|
||||
'nf' => nf.to_s
|
||||
),
|
||||
assunto: "Alteração na operação #{operacao}",
|
||||
corpo: "🔄 #{operacao}: #{o_que_mudou}#{nf.present? ? " (NF #{nf})" : ''}"
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Gatilhos] operacao_alterada: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
# Resumo periódico de um evento `agendado`.
|
||||
def resumo_agendado(evento, inicio:, fim:)
|
||||
numeros = Analytics::ResumoOperacao.new(inicio: inicio, fim: fim).numeros
|
||||
|
||||
Despachante.disparar(
|
||||
chave: evento.chave,
|
||||
dados: Variaveis.comuns_reais.merge(numeros).merge(
|
||||
'periodo' => periodo(inicio, fim)
|
||||
),
|
||||
assunto: "Resumo da operação — #{periodo(inicio, fim)}",
|
||||
corpo: corpo_resumo(numeros, inicio, fim)
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Gatilhos] resumo_agendado #{evento.chave}: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
# ── Auxiliares ─────────────────────────────────────────────
|
||||
|
||||
def corpo_resumo(numeros, inicio, fim)
|
||||
"📊 Resumo da operação — #{periodo(inicio, fim)}\n" \
|
||||
"• Entregues: #{numeros['entregues']}\n" \
|
||||
"• Não entregues: #{numeros['recusas']}\n" \
|
||||
"• Em aberto: #{numeros['pendentes']}\n" \
|
||||
"• Retentativas: #{numeros['retentativas']}\n" \
|
||||
"• Notas fora da operação: #{numeros['fora_operacao']}"
|
||||
end
|
||||
|
||||
def periodo(inicio, fim)
|
||||
"#{inicio&.strftime('%d/%m/%Y')} a #{fim&.strftime('%d/%m/%Y')}"
|
||||
end
|
||||
|
||||
def link_painel
|
||||
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
end
|
||||
|
||||
# Mesma busca do NotificacaoService (casa pelo nome, que é o que a
|
||||
# consolidação guarda). Só roda em consolidação finalizada, então o custo
|
||||
# por recálculo é irrelevante.
|
||||
def motorista_usuario(consolidacao_motorista)
|
||||
User.motorista.ativos.find_by('LOWER(nome) = ?', consolidacao_motorista.motorista_nome.to_s.downcase)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def contar_entregas(consolidacao, consolidacao_motorista)
|
||||
consolidacao.consolidacao_entregas
|
||||
.where(motorista_nome: consolidacao_motorista.motorista_nome)
|
||||
.where.not(tipo: :desconto)
|
||||
.count
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
# Formatação vive em Notificacao::Formatador (era copiada aqui e no
|
||||
# NotificacaoService; com o catálogo de variáveis seriam três cópias).
|
||||
def moeda(valor) = Formatador.moeda(valor)
|
||||
end
|
||||
end
|
||||
@@ -1,159 +0,0 @@
|
||||
# app/services/notificacao/renderizador.rb
|
||||
#
|
||||
# Blocos + dados => mensagem pronta. Duas saídas do MESMO template:
|
||||
#
|
||||
# #texto → WhatsApp (texto puro, com o *negrito* do WhatsApp)
|
||||
# #html → e-mail (tabelas com estilo inline, que é o que cliente de e-mail
|
||||
# renderiza de forma previsível)
|
||||
#
|
||||
# ⚠️ TODO texto vindo do editor e TODO valor de variável passa por escape no
|
||||
# caminho HTML. O corpo é digitado numa tela e viraria injeção de HTML no
|
||||
# e-mail — e o preview usa este mesmo renderizador, então um XSS aqui
|
||||
# atingiria primeiro o próprio admin.
|
||||
#
|
||||
# O preview da tela chama exatamente estes métodos: uma segunda implementação em
|
||||
# JavaScript inevitavelmente divergiria do que é enviado de verdade.
|
||||
module Notificacao
|
||||
class Renderizador
|
||||
# Cores fixas: cliente de e-mail não lê CSS externo nem variável de tema.
|
||||
LARANJA = '#f97316'.freeze
|
||||
ESCURO = '#111111'.freeze
|
||||
CINZA = '#666666'.freeze
|
||||
|
||||
def initialize(blocos, dados = {})
|
||||
@blocos = Array(blocos).select { |b| b.is_a?(Hash) && Blocos.valido?(b['tipo']) }
|
||||
@dados = (dados || {}).transform_keys(&:to_s)
|
||||
end
|
||||
|
||||
# ── WhatsApp ────────────────────────────────────────────────
|
||||
def texto
|
||||
@blocos.filter_map { |bloco| texto_do(bloco) }.join("\n\n").strip
|
||||
end
|
||||
|
||||
# ── E-mail ──────────────────────────────────────────────────
|
||||
def html
|
||||
corpo = @blocos.filter_map { |bloco| html_do(bloco) }.join("\n")
|
||||
<<~HTML
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#{ESCURO};line-height:1.6;max-width:600px;">
|
||||
#{corpo}
|
||||
</div>
|
||||
HTML
|
||||
end
|
||||
|
||||
# Substitui {{variavel}} pelos dados. Variável sem valor vira string vazia —
|
||||
# deixar "{{valor}}" cru numa mensagem enviada é pior do que deixar o espaço.
|
||||
def interpolar(texto)
|
||||
texto.to_s.gsub(/\{\{\s*(\w+)\s*\}\}/) { @dados[Regexp.last_match(1)].to_s }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def escapar(valor) = ERB::Util.html_escape(interpolar(valor))
|
||||
|
||||
def texto_do(bloco)
|
||||
case bloco['tipo']
|
||||
when 'cabecalho'
|
||||
[negrito(bloco['titulo']), interpolar(bloco['subtitulo'])].reject(&:blank?).join("\n").presence
|
||||
when 'texto', 'rodape'
|
||||
interpolar(bloco['texto']).presence
|
||||
when 'aviso'
|
||||
conteudo = interpolar(bloco['texto'])
|
||||
conteudo.present? ? "⚠️ #{conteudo}" : nil
|
||||
when 'tabela'
|
||||
linhas = Array(bloco['linhas']).filter_map do |linha|
|
||||
next unless linha.is_a?(Hash)
|
||||
rotulo = interpolar(linha['rotulo'])
|
||||
valor = interpolar(linha['valor'])
|
||||
next if rotulo.blank? && valor.blank?
|
||||
"• #{rotulo}: #{valor}".strip
|
||||
end
|
||||
linhas.any? ? linhas.join("\n") : nil
|
||||
when 'botao'
|
||||
url = interpolar(bloco['url'])
|
||||
return nil if url.blank?
|
||||
[interpolar(bloco['rotulo']).presence, url].compact.join(': ')
|
||||
when 'divisor'
|
||||
'——————————'
|
||||
end
|
||||
end
|
||||
|
||||
def negrito(valor)
|
||||
conteudo = interpolar(valor)
|
||||
conteudo.present? ? "*#{conteudo}*" : ''
|
||||
end
|
||||
|
||||
def html_do(bloco)
|
||||
case bloco['tipo']
|
||||
when 'cabecalho' then html_cabecalho(bloco)
|
||||
when 'texto' then html_paragrafo(bloco['texto'])
|
||||
when 'aviso' then html_aviso(bloco)
|
||||
when 'tabela' then html_tabela(bloco)
|
||||
when 'botao' then html_botao(bloco)
|
||||
when 'divisor' then %(<hr style="border:none;border-top:1px solid #e5e5e5;margin:20px 0;">)
|
||||
when 'rodape' then html_rodape(bloco)
|
||||
end
|
||||
end
|
||||
|
||||
def html_cabecalho(bloco)
|
||||
titulo = escapar(bloco['titulo'])
|
||||
sub = escapar(bloco['subtitulo'])
|
||||
return nil if titulo.blank? && sub.blank?
|
||||
|
||||
partes = []
|
||||
partes << %(<h1 style="margin:0 0 4px;font-size:20px;color:#{ESCURO};">#{titulo}</h1>) if titulo.present?
|
||||
partes << %(<p style="margin:0 0 16px;font-size:14px;color:#{CINZA};">#{sub}</p>) if sub.present?
|
||||
partes.join("\n")
|
||||
end
|
||||
|
||||
# `simple_format` não serve aqui: ele produz HTML sem os estilos inline que o
|
||||
# cliente de e-mail precisa. As quebras de linha viram <br>, já escapadas.
|
||||
def html_paragrafo(valor)
|
||||
conteudo = escapar(valor)
|
||||
return nil if conteudo.blank?
|
||||
|
||||
%(<p style="margin:0 0 14px;">#{conteudo.gsub("\n", '<br>')}</p>)
|
||||
end
|
||||
|
||||
def html_aviso(bloco)
|
||||
conteudo = escapar(bloco['texto'])
|
||||
return nil if conteudo.blank?
|
||||
|
||||
%(<div style="margin:0 0 16px;padding:12px 14px;background:#fff8e1;border-left:4px solid #f0ad4e;) +
|
||||
%(font-size:14px;color:#5c4400;">#{conteudo.gsub("\n", '<br>')}</div>)
|
||||
end
|
||||
|
||||
def html_tabela(bloco)
|
||||
linhas = Array(bloco['linhas']).filter_map do |linha|
|
||||
next unless linha.is_a?(Hash)
|
||||
rotulo = escapar(linha['rotulo'])
|
||||
valor = escapar(linha['valor'])
|
||||
next if rotulo.blank? && valor.blank?
|
||||
|
||||
%(<tr><td style="padding:8px 12px;border-bottom:1px solid #eee;color:#{CINZA};">#{rotulo}</td>) +
|
||||
%(<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold;">#{valor}</td></tr>)
|
||||
end
|
||||
return nil if linhas.empty?
|
||||
|
||||
%(<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin:0 0 16px;font-size:14px;">) +
|
||||
linhas.join + '</table>'
|
||||
end
|
||||
|
||||
def html_botao(bloco)
|
||||
url = interpolar(bloco['url']).to_s.strip
|
||||
# Só http(s): `javascript:` num href montado na tela seria clique armado.
|
||||
return nil unless url.match?(%r{\Ahttps?://}i)
|
||||
|
||||
rotulo = escapar(bloco['rotulo']).presence || 'Abrir'
|
||||
%(<p style="margin:0 0 18px;"><a href="#{ERB::Util.html_escape(url)}" ) +
|
||||
%(style="display:inline-block;padding:11px 20px;background:#{LARANJA};color:#000;) +
|
||||
%(text-decoration:none;border-radius:8px;font-weight:bold;font-size:14px;">#{rotulo}</a></p>)
|
||||
end
|
||||
|
||||
def html_rodape(bloco)
|
||||
conteudo = escapar(bloco['texto'])
|
||||
return nil if conteudo.blank?
|
||||
|
||||
%(<p style="margin:24px 0 0;font-size:12px;color:#999;">#{conteudo.gsub("\n", '<br>')}</p>)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,90 +0,0 @@
|
||||
# app/services/notificacao/resolvedor_variaveis.rb
|
||||
#
|
||||
# Completa o contexto de um disparo com as variáveis que o template REALMENTE
|
||||
# usa. É o que permite escrever {{entregas_mes}} ou {{telefone_suporte}} numa
|
||||
# mensagem sem que o gatilho precise saber dessas variáveis.
|
||||
#
|
||||
# SOB DEMANDA, de propósito: `{{entregas_mes}}` é uma query no espelho de
|
||||
# rastreio. Resolver o catálogo inteiro em todo disparo faria dezenas de
|
||||
# consultas para preencher variáveis que ninguém escreveu.
|
||||
#
|
||||
# Precedência (a primeira que responde vence):
|
||||
# 1. o que o gatilho já mandou (contexto real do acontecimento);
|
||||
# 2. variável de sistema (empresa, data, hora…);
|
||||
# 3. variável de dados (consulta ao banco);
|
||||
# 4. variável personalizada do ADM.
|
||||
#
|
||||
# ⚠️ NADA aqui levanta. Uma variável que falha vira string vazia — a mesma regra
|
||||
# do renderizador, e pelo mesmo motivo: mensagem com buraco é ruim, disparo
|
||||
# derrubado por causa de um texto é pior.
|
||||
module Notificacao
|
||||
module ResolvedorVariaveis
|
||||
MARCADOR = /\{\{\s*(\w+)\s*\}\}/
|
||||
|
||||
module_function
|
||||
|
||||
# textos: tudo que será interpolado (blocos do template, assunto, corpo).
|
||||
# dados: o contexto que o gatilho produziu.
|
||||
def completar(textos, dados = {})
|
||||
contexto = (dados || {}).transform_keys(&:to_s)
|
||||
faltantes = usadas(textos) - contexto.keys
|
||||
|
||||
faltantes.each_with_object(contexto.dup) do |chave, resultado|
|
||||
valor = resolver(chave)
|
||||
resultado[chave] = valor unless valor.nil?
|
||||
end
|
||||
end
|
||||
|
||||
# Chaves {{assim}} encontradas em qualquer texto (String, Array ou Hash de
|
||||
# blocos — o template é jsonb aninhado).
|
||||
def usadas(textos)
|
||||
varrer(textos).scan(MARCADOR).flatten.uniq
|
||||
end
|
||||
|
||||
def resolver(chave)
|
||||
chave = chave.to_s
|
||||
valor = valor_de_sistema(chave) || valor_de_dados(chave) || valor_personalizado(chave)
|
||||
valor&.to_s
|
||||
end
|
||||
|
||||
# ── privados de módulo ──────────────────────────────────────
|
||||
def varrer(objeto)
|
||||
case objeto
|
||||
when String then objeto
|
||||
when Hash then objeto.values.map { |v| varrer(v) }.join(' ')
|
||||
when Array then objeto.map { |v| varrer(v) }.join(' ')
|
||||
else ''
|
||||
end
|
||||
end
|
||||
|
||||
def valor_de_sistema(chave)
|
||||
meta = CatalogoVariaveis::SISTEMA[chave]
|
||||
meta && executar(meta[:resolver], chave)
|
||||
end
|
||||
|
||||
def valor_de_dados(chave)
|
||||
meta = CatalogoVariaveis::DADOS[chave]
|
||||
meta && executar(meta[:resolver], chave)
|
||||
end
|
||||
|
||||
def valor_personalizado(chave)
|
||||
return nil unless VariavelPersonalizada.table_exists?
|
||||
|
||||
VariavelPersonalizada.ativas.find_by(chave: chave)&.valor
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Variaveis] personalizada #{chave}: #{e.class}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def executar(bloco, chave)
|
||||
return nil if bloco.nil?
|
||||
|
||||
resultado = bloco.call
|
||||
# Número cru numa mensagem fica sem separador de milhar ("4977").
|
||||
resultado.is_a?(Numeric) ? Formatador.numero(resultado) : resultado.to_s
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Variaveis] #{chave}: #{e.class}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,9 @@
|
||||
# app/services/notificacao/teste_whatsapp.rb
|
||||
#
|
||||
# Dispara um WhatsApp de teste para o número administrativo, pelo provedor
|
||||
# escolhido na tela (QR/Baileys ou Twilio).
|
||||
#
|
||||
# No Twilio, traduz o código de erro da API para uma instrução acionável em
|
||||
# português — os erros de Sandbox (opt-in) são de longe os mais comuns e o
|
||||
# código cru ("63003") não diz nada para quem está configurando.
|
||||
# Dispara um WhatsApp de teste pelo Twilio para o número administrativo e
|
||||
# traduz o código de erro da API para uma instrução acionável em português —
|
||||
# os erros de Sandbox (opt-in) são de longe os mais comuns e o código cru
|
||||
# ("63003") não diz nada para quem está configurando.
|
||||
module Notificacao
|
||||
class TesteWhatsapp
|
||||
MENSAGENS = {
|
||||
@@ -28,8 +26,7 @@ module Notificacao
|
||||
|
||||
def initialize(config = ConfiguracaoNotificacao.instancia, destino: nil)
|
||||
@config = config
|
||||
@numero = ConfiguracaoNotificacao.normalizar_telefone(destino.presence || config.whatsapp_admin)
|
||||
@destino = @numero && ConfiguracaoNotificacao.canal(@numero)
|
||||
@destino = ConfiguracaoNotificacao.canal(destino.presence || config.whatsapp_admin)
|
||||
end
|
||||
|
||||
def executar
|
||||
@@ -38,21 +35,6 @@ module Notificacao
|
||||
'(ex.: 11 92005-1157) antes de testar.')
|
||||
end
|
||||
|
||||
@config.baileys? ? via_qr : via_twilio
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Modo QR: quem responde é a ponte Baileys (container `whatsapp`). Os erros
|
||||
# dela já vêm traduzidos pelo ClienteWhatsapp.
|
||||
def via_qr
|
||||
resposta = ClienteWhatsapp.padrao.enviar(para: @numero, texto: corpo)
|
||||
return Resultado.falha(resposta.erro.to_s) unless resposta.ok?
|
||||
|
||||
Resultado.sucesso("WhatsApp de teste enviado para #{@numero} pela ponte QR.")
|
||||
end
|
||||
|
||||
def via_twilio
|
||||
credenciais = @config.credenciais_whatsapp
|
||||
if credenciais.blank?
|
||||
return Resultado.falha('Marque "Ativar WhatsApp" e preencha Account SID, Auth Token e o ' \
|
||||
@@ -81,9 +63,11 @@ module Notificacao
|
||||
Resultado.falha("Falha inesperada ao enviar (#{e.class}).", e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def corpo
|
||||
"✅ Teste do Reem Notas\n" \
|
||||
"Se você recebeu esta mensagem, o envio de WhatsApp está funcionando.\n" \
|
||||
"Se você recebeu esta mensagem, a integração com o Twilio está funcionando.\n" \
|
||||
"Enviado em #{Time.current.strftime('%d/%m/%Y às %H:%M')}."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# app/services/notificacao/variaveis.rb
|
||||
#
|
||||
# Quais {{variaveis}} existem em cada gatilho, o que elas significam e um valor
|
||||
# de AMOSTRA para o preview do editor.
|
||||
#
|
||||
# A amostra é o que faz o preview valer alguma coisa: mostrar "{{valor}}" cru na
|
||||
# tela não diz se a mensagem ficou boa. Aqui ela é um exemplo plausível, não um
|
||||
# dado real do banco — o preview não deve depender de haver consolidação
|
||||
# fechada no ambiente para funcionar.
|
||||
module Notificacao
|
||||
module Variaveis
|
||||
# Disponíveis em qualquer evento.
|
||||
COMUNS = {
|
||||
'empresa' => { descricao: 'Nome da empresa', amostra: 'Reem Transportes' },
|
||||
'data' => { descricao: 'Data de hoje', amostra: '24/08/2026' },
|
||||
'contato' => { descricao: 'Primeiro nome de quem recebe', amostra: 'Carlos' }
|
||||
}.freeze
|
||||
|
||||
# Gatilhos que falam de um fechamento/pagamento de motorista.
|
||||
FECHAMENTO = {
|
||||
'motorista' => { descricao: 'Nome do motorista', amostra: 'Carlos Matheus Pimentel' },
|
||||
'consolidacao' => { descricao: 'Nome da consolidação', amostra: 'UBS NORTE 01–14/08' },
|
||||
'periodo' => { descricao: 'Período do fechamento', amostra: '01/08/2026 a 14/08/2026' },
|
||||
'valor' => { descricao: 'Valor do motorista', amostra: 'R$ 5.060,00' },
|
||||
'entregas' => { descricao: 'Entregas atendidas', amostra: '280' },
|
||||
'link_painel' => { descricao: 'Link do painel do motorista', amostra: 'https://app.reem.com.br/motorista' }
|
||||
}.freeze
|
||||
|
||||
POR_GATILHO = {
|
||||
'manual' => {},
|
||||
'consolidacao_finalizada' => FECHAMENTO,
|
||||
'pagamento_efetuado' => FECHAMENTO,
|
||||
'valor_alterado' => FECHAMENTO.merge(
|
||||
'o_que_mudou' => { descricao: 'O que foi alterado', amostra: 'Desconto de R$ 120,00 aplicado (avaria)' }
|
||||
),
|
||||
'operacao_alterada' => {
|
||||
'operacao' => { descricao: 'Operação afetada', amostra: 'UBS NORTE AGO 2026' },
|
||||
'o_que_mudou' => { descricao: 'O que foi alterado', amostra: '3 NFs entraram, 1 saiu' },
|
||||
'nf' => { descricao: 'Nota fiscal', amostra: '85382' }
|
||||
},
|
||||
'agendado' => {
|
||||
'operacao' => { descricao: 'Operação', amostra: 'UBS NORTE AGO 2026' },
|
||||
'periodo' => { descricao: 'Período do resumo', amostra: '01/08/2026 a 24/08/2026' },
|
||||
'entregues' => { descricao: 'Notas entregues', amostra: '4.851' },
|
||||
'recusas' => { descricao: 'Notas não entregues', amostra: '122' },
|
||||
'pendentes' => { descricao: 'Notas em aberto', amostra: '0' },
|
||||
'retentativas' => { descricao: 'Visitas além da primeira', amostra: '4' },
|
||||
'fora_operacao' => { descricao: 'Notas fora da operação', amostra: '2' }
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def self.para(gatilho)
|
||||
COMUNS.merge(POR_GATILHO.fetch(gatilho.to_s, {}))
|
||||
end
|
||||
|
||||
def self.nomes(gatilho) = para(gatilho).keys
|
||||
|
||||
# { 'valor' => 'R$ 5.060,00', ... } — o contexto do PREVIEW.
|
||||
#
|
||||
# ⚠️ Só para preview. Num envio de verdade a amostra colocaria uma data fixa
|
||||
# e um valor inventado dentro da mensagem que o contato recebe.
|
||||
def self.amostra(gatilho)
|
||||
para(gatilho).transform_values { |meta| meta[:amostra] }
|
||||
end
|
||||
|
||||
# Valores REAIS das variáveis comuns — o que um envio de verdade deve usar
|
||||
# quando o gatilho não traz contexto próprio (ex.: disparo manual).
|
||||
def self.comuns_reais
|
||||
{
|
||||
'empresa' => Configuracao.valor('empresa_nome').presence || 'Reem Transportes',
|
||||
'data' => Date.current.strftime('%d/%m/%Y')
|
||||
}
|
||||
rescue StandardError
|
||||
# Tabela de configuração indisponível não pode impedir um disparo.
|
||||
{ 'data' => Date.current.strftime('%d/%m/%Y') }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,51 +0,0 @@
|
||||
# app/services/notificacao/whatsapp.rb
|
||||
#
|
||||
# Ponto ÚNICO de envio de WhatsApp: escolhe o provedor configurado e devolve
|
||||
# sempre a mesma resposta, venha de onde vier.
|
||||
#
|
||||
# Existe para que trocar Twilio ↔ Baileys seja um campo na tela, e não um `if`
|
||||
# espalhado por cada chamador. O NotificacaoService (aviso ao motorista) e o
|
||||
# Despachante (grupos) passam os dois por aqui.
|
||||
module Notificacao
|
||||
module Whatsapp
|
||||
Resposta = ClienteWhatsapp::Resposta
|
||||
|
||||
def self.enviar(para:, texto:, config: ConfiguracaoNotificacao.instancia)
|
||||
# Grupo do WhatsApp vem como JID pronto ("120363...@g.us") — não é
|
||||
# telefone, não normaliza. Passa direto para a ponte, que aceita as duas
|
||||
# formas.
|
||||
if Contato::JID_GRUPO_RE.match?(para.to_s.strip)
|
||||
return Resposta.new(ok: false, erro: 'O Twilio não envia para grupo do WhatsApp. ' \
|
||||
'Troque o provedor para QR code.') unless config.baileys?
|
||||
|
||||
return via_baileys(para.to_s.strip, texto)
|
||||
end
|
||||
|
||||
destino = ConfiguracaoNotificacao.normalizar_telefone(para)
|
||||
return Resposta.new(ok: false, erro: 'Número inválido.') if destino.blank?
|
||||
|
||||
config.baileys? ? via_baileys(destino, texto) : via_twilio(destino, texto, config)
|
||||
end
|
||||
|
||||
def self.via_baileys(destino, texto)
|
||||
ClienteWhatsapp.padrao.enviar(para: destino, texto: texto)
|
||||
end
|
||||
|
||||
def self.via_twilio(destino, texto, config)
|
||||
credenciais = config.credenciais_whatsapp
|
||||
if credenciais.blank? || credenciais[:from].blank?
|
||||
return Resposta.new(ok: false, erro: 'Twilio sem credenciais configuradas.')
|
||||
end
|
||||
|
||||
ClienteTwilio.montar(credenciais[:sid], credenciais[:token])
|
||||
.messages.create(from: credenciais[:from],
|
||||
to: ConfiguracaoNotificacao.canal(destino),
|
||||
body: texto)
|
||||
Resposta.new(ok: true)
|
||||
rescue StandardError => e
|
||||
# Falha de envio nunca pode subir: quem chama está no meio de um
|
||||
# fechamento de pagamento.
|
||||
Resposta.new(ok: false, erro: "#{e.class}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,12 +13,10 @@
|
||||
class NotificacaoService
|
||||
EVENTOS = {
|
||||
fechado: { mailer: :pagamento_fechado,
|
||||
chave: 'consolidacao_finalizada',
|
||||
emoji: '🚚',
|
||||
titulo: 'Seu pagamento de entregas foi fechado.',
|
||||
cta: 'Acesse seu painel para baixar o extrato:' },
|
||||
pago: { mailer: :pagamento_efetuado,
|
||||
chave: 'pagamento_efetuado',
|
||||
emoji: '✅',
|
||||
titulo: 'Seu pagamento foi efetuado.',
|
||||
cta: 'Acesse seu painel para conferir:' }
|
||||
@@ -50,128 +48,45 @@ class NotificacaoService
|
||||
|
||||
def notificar(cm, evento)
|
||||
user = motorista_de(cm)
|
||||
return unless user
|
||||
|
||||
# Aviso pessoal ao motorista: continua com o e-mail formatado do
|
||||
# ConsolidacaoMailer (não vira texto puro) e passa a usar o provedor de
|
||||
# WhatsApp escolhido na tela — Baileys por QR ou Twilio.
|
||||
if user
|
||||
enviar_whatsapp(user, cm, evento) if @config.whatsapp_habilitado?
|
||||
enviar_email(user, cm, evento) if @config.email_habilitado? && user.email.present?
|
||||
end
|
||||
|
||||
# Cópia para os grupos que assinam este evento (diretoria, operação...).
|
||||
# `envolvido: nil` de propósito: o motorista já foi avisado acima e receberia
|
||||
# a mensagem duas vezes.
|
||||
avisar_grupos(cm, evento, user)
|
||||
end
|
||||
|
||||
# O corpo aqui é o mesmo texto do WhatsApp — na etapa do editor de blocos ele
|
||||
# passa a vir do template configurado pelo ADM.
|
||||
def avisar_grupos(cm, evento, user)
|
||||
evt = EVENTOS.fetch(evento)
|
||||
Notificacao::Despachante.disparar_gatilho(
|
||||
gatilho: evt[:chave],
|
||||
dados: variaveis(cm, user),
|
||||
assunto: "#{evt[:titulo]} — #{@consolidacao.nome}",
|
||||
corpo: mensagem_para_grupos(cm, evento, user)
|
||||
)
|
||||
end
|
||||
|
||||
# Contexto das {{variaveis}} do editor de blocos. As chaves são as mesmas de
|
||||
# Notificacao::Variaveis::FECHAMENTO — é o que o ADM vê na paleta da tela.
|
||||
def variaveis(cm, user)
|
||||
Notificacao::Variaveis.comuns_reais.merge(
|
||||
'motorista' => user&.nome.presence || cm.motorista_nome,
|
||||
'consolidacao' => @consolidacao.nome,
|
||||
'periodo' => "#{l_data(@consolidacao.data_inicio)} a #{l_data(@consolidacao.data_fim)}",
|
||||
'valor' => moeda(cm.valor_total),
|
||||
# ConsolidacaoMotorista não guarda contagem — só valor_total. As entregas
|
||||
# do motorista são as linhas classificadas na consolidação (mesma fonte do
|
||||
# #recalcular_valor), descontando os lançamentos de desconto, que não são
|
||||
# entregas.
|
||||
'entregas' => entregas_do_motorista(cm).to_s,
|
||||
'link_painel' => "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
)
|
||||
end
|
||||
|
||||
def l_data(data)
|
||||
data&.strftime('%d/%m/%Y').to_s
|
||||
end
|
||||
|
||||
def entregas_do_motorista(cm)
|
||||
@consolidacao.consolidacao_entregas
|
||||
.where(motorista_nome: cm.motorista_nome)
|
||||
.where.not(tipo: :desconto)
|
||||
.count
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[Notificacao] contagem de entregas indisponível: #{e.class}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def mensagem_para_grupos(cm, evento, user)
|
||||
evt = EVENTOS.fetch(evento)
|
||||
"#{evt[:emoji]} #{evt[:titulo]}\n" \
|
||||
"👤 #{user&.nome || cm.motorista_nome}\n" \
|
||||
"📋 #{@consolidacao.nome}\n" \
|
||||
"💰 Valor: #{moeda(cm.valor_total)}"
|
||||
end
|
||||
|
||||
def motorista_de(cm)
|
||||
User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
|
||||
end
|
||||
|
||||
def enviar_whatsapp(user, cm, evento)
|
||||
# O telefone no cadastro pode estar como "(11) 92005-1157"; a normalização
|
||||
# para E.164 acontece dentro de Notificacao::Whatsapp — mandar o texto cru
|
||||
# devolve erro de destino inválido que o rescue esconderia.
|
||||
destino = ConfiguracaoNotificacao.normalizar_telefone(user.telefone)
|
||||
# O telefone no cadastro pode estar como "(11) 92005-1157"; mandar esse
|
||||
# texto cru para o Twilio devolve erro 21211 (destino inválido), que o
|
||||
# rescue abaixo esconderia. Normalizar para E.164 é o que faz o envio
|
||||
# realmente funcionar.
|
||||
destino = ConfiguracaoNotificacao.canal(user.telefone)
|
||||
return if destino.blank?
|
||||
|
||||
texto = mensagem(user, cm, evento)
|
||||
envio = registrar_envio(evento, canal: 'whatsapp', destino: destino, user: user, corpo: texto)
|
||||
credenciais = @config.credenciais_whatsapp
|
||||
return if credenciais.blank? || credenciais[:from].blank?
|
||||
|
||||
Notificacao::ClienteTwilio
|
||||
.montar(credenciais[:sid], credenciais[:token])
|
||||
.messages.create(from: credenciais[:from], to: destino, body: mensagem(user, cm, evento))
|
||||
|
||||
resposta = Notificacao::Whatsapp.enviar(para: destino, texto: texto, config: @config)
|
||||
if resposta.ok?
|
||||
envio&.marcar_enviado!
|
||||
Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}")
|
||||
else
|
||||
envio&.marcar_falha!(resposta.erro)
|
||||
Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{resposta.erro}")
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
def enviar_email(user, cm, evento)
|
||||
evt = EVENTOS.fetch(evento)
|
||||
envio = registrar_envio(evento, canal: 'email', destino: user.email, user: user,
|
||||
corpo: mensagem(user, cm, evento), assunto: evt[:titulo])
|
||||
|
||||
ConsolidacaoMailer.public_send(evt[:mailer], user, @consolidacao, cm).deliver_later
|
||||
# `deliver_later` só ENFILEIRA: marcar como enviado aqui significa "saiu do
|
||||
# nosso lado". Uma falha do SMTP aparece no log do Rails, não aqui.
|
||||
envio&.marcar_enviado!
|
||||
ConsolidacaoMailer
|
||||
.public_send(EVENTOS.fetch(evento)[:mailer], user, @consolidacao, cm)
|
||||
.deliver_later
|
||||
Rails.logger.info("[Notificacao] Email (#{evento}) agendado para #{user.email}")
|
||||
rescue StandardError => e
|
||||
envio&.marcar_falha!("#{e.class}: #{e.message}")
|
||||
Rails.logger.error("[Notificacao] Falha email (#{evento}) #{user.email}: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
# Log do aviso pessoal. Nunca levanta: se a tabela ainda não existir (deploy
|
||||
# antes do migrate), o envio precisa seguir mesmo sem registro.
|
||||
def registrar_envio(evento, canal:, destino:, user:, corpo:, assunto: nil)
|
||||
NotificacaoEnvio.create!(
|
||||
evento_notificacao: evento_registrado(evento), user: user, canal: canal,
|
||||
destino: destino, assunto: assunto, corpo: corpo, status: 'pendente'
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[Notificacao] sem log de envio (#{e.class}: #{e.message})")
|
||||
nil
|
||||
end
|
||||
|
||||
def evento_registrado(evento)
|
||||
@eventos_cache ||= {}
|
||||
@eventos_cache[evento] ||= EventoNotificacao.find_by(chave: EVENTOS.fetch(evento)[:chave])
|
||||
end
|
||||
|
||||
def mensagem(user, cm, evento)
|
||||
evt = EVENTOS.fetch(evento)
|
||||
"Olá #{user.nome.split.first}! #{evt[:emoji]}\n" \
|
||||
@@ -181,6 +96,10 @@ class NotificacaoService
|
||||
"#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
end
|
||||
|
||||
# Ver Notificacao::Formatador — implementação única.
|
||||
def moeda(valor) = Notificacao::Formatador.moeda(valor)
|
||||
# Moeda BR com separador de milhar: R$ 1.234,56
|
||||
def moeda(valor)
|
||||
inteiro, decimais = format('%.2f', valor.to_f).split('.')
|
||||
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||
"R$ #{inteiro},#{decimais}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,15 +9,16 @@ module Pdf
|
||||
PRETO = '0A0A0A'
|
||||
CINZA = '6B7280'
|
||||
|
||||
# Fonte TrueType com suporte total a UTF-8 — o porquê está em Pdf::FonteUtf8,
|
||||
# que também serve o romaneio (paisagem, marca da GADE, sem herdar daqui).
|
||||
include Pdf::FonteUtf8
|
||||
# Fonte TrueType com suporte total a UTF-8. As fontes embutidas do Prawn
|
||||
# (AFM/Helvetica) só aceitam o charset Windows-1252 e estouram
|
||||
# Prawn::Errors::IncompatibleStringEncoding em caracteres como "✓".
|
||||
FONTS_DIR = Rails.root.join('app', 'assets', 'fonts')
|
||||
|
||||
def initialize
|
||||
# Margem inferior folgada (72) para o rodapé repetido (desenhado em y=20)
|
||||
# não sobrepor o conteúdo.
|
||||
@pdf = Prawn::Document.new(page_size: 'A4', margin: [40, 40, 72, 40])
|
||||
registrar_fonte_utf8(@pdf)
|
||||
registrar_fonte_utf8
|
||||
end
|
||||
|
||||
def render
|
||||
@@ -29,6 +30,24 @@ module Pdf
|
||||
|
||||
private
|
||||
|
||||
# Registra a família DejaVu Sans (TTF) e a define como fonte padrão.
|
||||
# Como os PDFs só usam normal e :bold, mapeamos italic/bold_italic para
|
||||
# as variantes existentes, evitando erro caso :italic seja solicitado.
|
||||
def registrar_fonte_utf8
|
||||
normal = FONTS_DIR.join('DejaVuSans.ttf').to_s
|
||||
bold = FONTS_DIR.join('DejaVuSans-Bold.ttf').to_s
|
||||
|
||||
@pdf.font_families.update(
|
||||
'DejaVu' => {
|
||||
normal: normal,
|
||||
bold: bold,
|
||||
italic: normal,
|
||||
bold_italic: bold
|
||||
}
|
||||
)
|
||||
@pdf.font 'DejaVu'
|
||||
end
|
||||
|
||||
def cabecalho
|
||||
@pdf.fill_color PRETO
|
||||
@pdf.fill_rectangle [-40, @pdf.cursor + 40], @pdf.bounds.width + 80, 70
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user