Compare commits

...

33 Commits

Author SHA256 Message Date
1143ed373b , correção de UX para gerara prévia do romaneio 2026-08-28 12:13:42 -03:00
b690dfb54c , correção para gerara prévia do romaneio 2026-08-28 12:00:53 -03:00
94b2e5a0a5 , correção da pagina de gerar romaneio 2026-08-28 11:43:41 -03:00
e778036df5 , correção da pagina de gerar romaneio e outros fix 2026-08-28 11:31:44 -03:00
c454cb8cc7 Criação da bateria de testes automatizados, correção da pagina de gerar romaneio e plano da criação do modulo cliente 2026-08-28 09:40:59 -03:00
b20299dd6b Atualização de Read.me e \correção da autorização no primeiro login 2026-08-28 02:19:08 -03:00
ab126d13db Atualização de Read.me 2026-08-27 19:57:33 -03:00
e2b74068d5 Implantação da criação de romaneios 2026-08-27 19:51:38 -03:00
b3464e3acd Romaneio: lista de veículos visível, busca com contador e logo escolhível
Traz para a tela as funções do programa original (Romaneiro PDF), na linha de
design do projeto:
- Veículos deixam de ser um <select> e viram lista visível com a contagem de
  paradas de cada carro — que é a conferência feita antes de imprimir e ficava
  invisível mostrando um veículo por vez. Cada item é link (?veiculo=), então
  sobrevive ao 'voltar' e ao F5. No celular vira faixa horizontal.
- Na mesma coluna: motorista do veículo, 'PDF deste veículo' e 'PDF de TODOS'.
- Busca ganha 'Limpar busca' e contador vivo ('4 de 31 paradas'). A contagem
  saiu do cabeçalho da tabela: dois números para a mesma coisa, um atualizando e
  o outro não, é a divergência que a diretriz 1 proíbe.
- Logo do cabeçalho escolhível (Configuracao 'romaneio_logo', separado do
  empresa_logo — o romaneio é documento do cliente). PNG/JPG até 2MB, gravado em
  storage/logos/ e não em public/, com nome gerado pelo sistema.
2026-08-27 19:20:49 -03:00
7fa2336b35 Corrige acesso em produção: hosts permitidos, i18n do Devise e config do ambiente no compose
- production.rb (hosts): a linha que parecia só liberar o APP_HOST na verdade
  LIGAVA a verificação de Host e recusava todo o resto — o acesso pelo IP
  interno caía em 'Blocked hosts: 100.75.222.23:3001'. Agora entram também
  localhost/127.0.0.1 e a lista HOSTS_PERMITIDOS.
- production.rb (i18n): 'fallbacks = true' significa cair no default_locale,
  que aqui é o próprio pt-BR — o fallback apontava para si mesmo e chave
  ausente virava 'Translation missing' na tela do usuário. Agora [:en], igual
  ao application.rb.
- pt-BR.yml: traduções do Devise (failure/sessions/passwords). O projeto não
  usa devise-i18n; em development o texto vinha em inglês pelo fallback e o
  buraco passava despercebido.
- docker-compose.yml / .env.example: porta do host parametrizada
  (${PORTA_APP:-3001}) e APP_HOST/APP_NAME/WHATSAPP_URL/HOSTS_PERMITIDOS com
  padrão — essa configuração vivia só no .env do servidor e sumiu junto com ele.
2026-08-27 18:30:17 -03:00
a06f93784c Corrige boot: storage.yml versionado e bin/docker-boot via bash 2026-08-27 18:03:49 -03:00
5a438d9812 Corrige boot: storage.yml versionado e bin/docker-boot via bash 2026-08-27 17:16:17 -03:00
ad1a071e4b Correção de horario e implantação da geração de Romaneio 2026-08-27 16:21:56 -03:00
a0aafe62fc Correção da pagina de variaveis 2026-08-26 23:43:36 -03:00
ed44d5fd85 Correção de vazamento de dados nas páginas de erro e nos logs
Três problemas de exposição, todos com o mesmo efeito: dado interno saindo
para quem não deveria ver.

1. Painel de debug do Rails na tela do usuário
   O servidor sobe com RAILS_ENV=development (docker-compose.yml), e
   development tinha `consider_all_requests_local = true`. A página
   "Action Controller: Exception caught" mostra parâmetros, SESSÃO
   (session_id, _csrf_token, id do usuário logado), cookies, IP do cliente,
   caminho do servidor e o trace inteiro — para qualquer pessoa que
   provocasse um erro. Agora o detalhe depende de ERROS_DETALHADOS=true,
   que só é ligado na máquina de desenvolvimento.

2. Senha e PIN em texto puro no log
   Não existia filter_parameter_logging.rb neste projeto: toda senha do
   login, PIN de motorista e token do WhatsApp iam para log/ em texto
   puro — e log vai junto em backup e em suporte. Era o pior dos três,
   porque é permanente e não aparece na tela de ninguém.

3. Páginas de erro do usuário final
   Sem public/500.html, desligar o painel deixaria tela em branco. Criadas
   500/404/422 em português, sem CSS externo (precisam funcionar com a
   aplicação fora do ar).

Também sai do versionamento a pasta Erros/, com 16 páginas de debug salvas
do navegador. Não há cookie de sessão assinado nelas — ninguém entra no
sistema com o que está ali — mas há session_id, token CSRF, id de usuário e
IPs, que não têm por que estar no repositório.

Correção estrutural pendente: subir o servidor com RAILS_ENV=production
(production.rb já tem consider_all_requests_local=false e force_ssl=true).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:44:58 -03:00
5bb5d74e22 Implandação da customização das permissões dos usuarios e das notificaçoes 2026-08-26 17:24:25 -03:00
808c2c7545 Modificação na area do motorista 2026-08-26 15:51:55 -03:00
044747a36b Modificação na area do motorista 2026-08-26 15:45:25 -03:00
e4b17653ef Modificação na area do motorista 2026-08-26 15:40:50 -03:00
6c78570658 Modificação na area do motorista 2026-08-26 15:34:56 -03:00
7a8ec1bc16 Criação da visão geral da consolidação por motorista e Estilização 2026-08-26 15:15:44 -03:00
cd99a22553 Criação da visão geral da consolidação por motorista 2026-08-26 11:15:52 -03:00
8d24a57579 Atualização do Para correção do valor do dash da operação 2026-08-26 10:56:07 -03:00
e272b96d0c Atualização do READ.me 2026-08-25 01:06:19 -03:00
985e2e3b55 Atualização para que possa mandar mesagens do Zap para grupos 2026-08-25 00:41:26 -03:00
7e093f3af8 Atualização das configuração de mesagens do Zap 2026-08-24 19:05:48 -03:00
568f185d8c Atualização do READ.ME 2026-08-24 19:00:21 -03:00
c4d691b202 Correção falha na build nova do docker 2026-08-24 17:58:23 -03:00
9451467b0a Correção falha na migration da ultima atualização 2026-08-24 17:46:24 -03:00
479c1481f4 Correção falha na migration da ultima atualização 2026-08-24 17:37:02 -03:00
88f19dd8f5 Implantação da geração de mensagem de forma livre e mudança na engine de mensagem 2026-08-24 17:32:16 -03:00
94bbe7556f Correção de itens que estão fora do plano valores no dash principal 2026-08-24 16:26:27 -03:00
04b78bc7de Correção de alguns valores no dash principal 2026-08-24 12:07:56 -03:00
234 changed files with 16655 additions and 55572 deletions

View File

@@ -9,8 +9,30 @@
# 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) ─────────────
# Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL
# ⚠️ 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".
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
@@ -76,5 +98,32 @@ SMTP_DOMAIN=gade.com.br
NOTIFICACAO_SECRET=
# ── App ───────────────────────────────────────────────────────
APP_HOST=localhost:3000
# ⚠️ 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_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

View File

@@ -1,6 +1,22 @@
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 \
@@ -10,6 +26,9 @@ 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
@@ -22,9 +41,14 @@ RUN bundle install --jobs 4 --retry 3
# Copia o restante do código
COPY . .
# Pré-compila assets (em produção)
# RUN bundle exec rails assets:precompile
# 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.
EXPOSE 3000
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
# 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"]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,276 +0,0 @@
<!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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,252 +0,0 @@
<!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>

View File

@@ -29,6 +29,11 @@ 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
@@ -47,6 +52,10 @@ 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

View File

@@ -1,6 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
Ascii85 (2.0.1)
actioncable (7.1.6)
actionpack (= 7.1.6)
activesupport (= 7.1.6)
@@ -82,6 +83,7 @@ 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)
@@ -100,6 +102,11 @@ 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)
@@ -139,6 +146,8 @@ 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)
@@ -192,6 +201,13 @@ 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
@@ -269,6 +285,9 @@ 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)
@@ -290,7 +309,7 @@ GEM
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.7)
rubyzip (3.4.0)
rubyzip (2.4.1)
securerandom (0.4.1)
selenium-webdriver (4.44.0)
base64 (~> 0.2)
@@ -344,6 +363,7 @@ PLATFORMS
DEPENDENCIES
annotate
capybara
caxlsx
chunky_png
debug
devise
@@ -354,6 +374,7 @@ DEPENDENCIES
importmap-rails
jbuilder
pagy (~> 9.0)
pdf-inspector
pg (~> 1.1)
prawn
prawn-table
@@ -362,6 +383,7 @@ DEPENDENCIES
pundit
rack-mini-profiler
rails (~> 7.1.0)
roo (~> 2.10)
rqrcode
rspec-rails
selenium-webdriver

1597
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
/* 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.
*/

View File

@@ -1,7 +1,10 @@
# app/controllers/admin/auditoria_logs_controller.rb
class Admin::AuditoriaLogsController < ApplicationController
def index
authorize AuditoriaLog, policy_class: ApplicationPolicy
# 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
@logs = AuditoriaLog.recentes.limit(200)
@logs = @logs.por_user(params[:user_id]) if params[:user_id].present?

View File

@@ -9,7 +9,9 @@ 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 twilio_account_sid twilio_auth_token twilio_from whatsapp_admin
whatsapp_ativo whatsapp_provedor whatsapp_intervalo_segundos whatsapp_admin
twilio_account_sid twilio_auth_token twilio_from
baileys_url baileys_token
].freeze
def show
@@ -60,6 +62,7 @@ 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
@@ -79,11 +82,15 @@ 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?
twilio_auth_token_definido: @config.twilio_auth_token?,
baileys_token_definido: @config.baileys_token?
}
end

View File

@@ -0,0 +1,90 @@
# 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

View File

@@ -118,6 +118,15 @@ 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) }
@@ -157,6 +166,18 @@ 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

View File

@@ -0,0 +1,140 @@
# 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

View File

@@ -0,0 +1,74 @@
# 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

View File

@@ -0,0 +1,75 @@
# 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

View File

@@ -0,0 +1,23 @@
# 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

View File

@@ -0,0 +1,110 @@
# 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

View File

@@ -0,0 +1,340 @@
# 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
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

View File

@@ -4,7 +4,7 @@ class Admin::UsuariosController < ApplicationController
def index
authorize User
@usuarios = policy_scope(User).order(:nome).limit(200)
@usuarios = policy_scope(User).includes(:perfil_acesso).order(:nome).limit(200)
@roles = User.roles.keys
end
@@ -36,6 +36,13 @@ 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.'
@@ -47,6 +54,10 @@ 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
@@ -58,7 +69,11 @@ class Admin::UsuariosController < ApplicationController
end
def toggle_ativo
authorize @usuario, :update?
authorize @usuario, :toggle_ativo?
if @usuario.ativo? && User.ultimo_administrador?(@usuario)
return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN)
end
@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)
@@ -67,18 +82,53 @@ 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
params.require(:user).permit(:nome, :email, :password, :password_confirmation,
:role, :pin_code, :ativo)
permitidos = [:nome, :email, :password, :password_confirmation, :pin_code]
permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso?
params.require(:user).permit(permitidos)
end
def usuario_params_update
permitted = [:nome, :email, :role, :pin_code, :ativo]
permitted += [:password, :password_confirmation] if params[:user][:password].present?
params.require(:user).permit(permitted)
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')
end
end
helper_method :pode_alterar_acesso?
end

View File

@@ -0,0 +1,74 @@
# 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

View File

@@ -0,0 +1,80 @@
# 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

View File

@@ -7,14 +7,31 @@ class ApplicationController < ActionController::Base
before_action :set_tema
before_action :preparar_splash
# 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
# 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
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
@@ -28,8 +45,11 @@ 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
resource.motorista? ? motorista_dashboard_path : dashboard_path
rota = resource.home_rota
rota ? public_send(rota) : sem_acesso_path
end
end

View File

@@ -433,7 +433,7 @@ class ConsolidacaoEntregasController < ApplicationController
end
def bloquear_se_finalizada
return unless @consolidacao.finalizada? && current_user.operador?
return unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
end

View File

@@ -13,17 +13,37 @@ class ConsolidacoesController < ApplicationController
# Formas de pagamento aceitas (o select da UI usa esta mesma lista).
FORMAS_PAGAMENTO = %w[pix transferencia dinheiro].freeze
# GET /consolidacoes — lista com filtros
# 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.
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?
@consolidacoes = @consolidacoes.where('data_inicio >= ?', params[:inicio]) if params[:inicio].present?
@consolidacoes = @consolidacoes.where('data_fim <= ?', params[:fim]) if params[:fim].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/0826/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
if params[:motorista].present?
ids = ConsolidacaoMotorista.where('motorista_nome ILIKE ?', "%#{params[:motorista]}%")
@@ -38,6 +58,30 @@ 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
@@ -201,7 +245,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/finalizar
def finalizar
authorize @consolidacao, :update?
authorize @consolidacao, :finalizar?
if @consolidacao.finalizar!(current_user)
auditar!(:finalizar, @consolidacao)
@@ -218,7 +262,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, :update?
authorize @consolidacao, :finalizar_parcial?
arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user)
if @consolidacao.finalizar!(current_user)
@@ -234,7 +278,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/arquivar
def arquivar
authorize @consolidacao, :destroy?
authorize @consolidacao, :arquivar?
@consolidacao.arquivar!(current_user)
auditar!(:arquivar, @consolidacao)
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
@@ -248,7 +292,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada
def reativar
authorize @consolidacao, :destroy?
authorize @consolidacao, :reativar?
@consolidacao.reativar!(current_user)
auditar!(:reativar, @consolidacao)
redirect_to consolidacoes_path, notice: 'Consolidação reativada.'
@@ -306,7 +350,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, :update?
authorize @consolidacao, :gerir_motoristas?
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
if cm.pago?
@@ -323,7 +367,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado.
def desarquivar_motorista
authorize @consolidacao, :update?
authorize @consolidacao, :gerir_motoristas?
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
cm.desarquivar!(current_user)
@@ -338,7 +382,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, :destroy?
authorize @consolidacao, :excluir_motorista?
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
if cm.pago?
@@ -363,7 +407,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, :update?
authorize @consolidacao, :gerir_motoristas?
nome = params[:motorista_nome].to_s.strip
if nome.blank?
@@ -391,7 +435,7 @@ class ConsolidacoesController < ApplicationController
# GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X
def gerar_pdf_relatorio
authorize @consolidacao, :show?
authorize @consolidacao, :exportar_pdf?
motorista = params[:motorista]
pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista)
@@ -405,7 +449,7 @@ class ConsolidacoesController < ApplicationController
# GET /consolidacoes/:id/gerar_pdf_financeiro — relatório financeiro da consolidação
def gerar_pdf_financeiro
authorize @consolidacao, :show?
authorize @consolidacao, :exportar_pdf?
pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao)
auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' })
@@ -449,6 +493,40 @@ 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).

View File

@@ -3,11 +3,16 @@ 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
@@ -15,9 +20,11 @@ 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
@@ -50,6 +57,41 @@ 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).
@@ -102,6 +144,13 @@ 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
@@ -138,12 +187,13 @@ class DashboardController < ApplicationController
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
# 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
# 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
carregar_dados_pagamentos
carregar_dados_financeiro
end
# Histórico estimado mais recente
@historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5)

View File

@@ -3,47 +3,24 @@ module Motorista
class DashboardController < ApplicationController
before_action :garantir_motorista
# GET /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.
def index
nome = current_user.nome
# 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
# aconteceu e teve custo, então a empresa paga os dois. É o MESMO recorte do
# dashboard administrativo e da consolidação (ver Entrega::STATUS_ATENDIDO e
# Entrega.contar_atendidas): usava `pagas` (só concluídas) e o motorista via
# um valor MENOR que o do fechamento, sem entender de onde vinha a diferença.
#
# ⚠️ 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.
@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)
@entregas_concluidas = atendidas.concluidas.count
@entregas_falhadas = atendidas.falhadas.count
@entregas_mes = @entregas_concluidas + @entregas_falhadas
@valor_estimado = @entregas_mes * Configuracao.preco_entrega
@inicio, @fim = periodo_selecionado
@faixa_total = faixa_completa(nome) # para o atalho "Tudo"
# 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 })
.recentes
.distinct
@valor_consolidado = ConsolidacaoMotorista.ativos
.joins(:consolidacao)
.where(motorista_nome: nome,
consolidacoes: { status: :finalizada, deleted_at: nil })
.sum(:valor_total)
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).
# 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,
@@ -54,8 +31,96 @@ module Motorista
.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.
#
# Atendida = o motorista FOI ao local e fechou a visita, com sucesso
# ('completed') OU insucesso ('failed') — no insucesso o deslocamento
# aconteceu e teve custo, então a empresa paga os dois. É o MESMO recorte do
# dashboard administrativo e da consolidação (ver Entrega::STATUS_ATENDIDO e
# Entrega.contar_atendidas): usava `pagas` (só concluídas) e o motorista via
# um valor MENOR que o do fechamento, sem entender de onde vinha a diferença.
#
# ⚠️ 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
atendidas = Entrega.atendidas.da_conta_gade
.do_motorista(nome)
.no_periodo_checkout(@estimado_inicio, @estimado_fim)
@entregas_concluidas = atendidas.concluidas.count
@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
.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?
[inicio.to_date, fim.to_date]
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
def garantir_motorista
return if current_user.motorista?
redirect_to dashboard_path, alert: 'Área exclusiva dos motoristas.'

View File

@@ -16,6 +16,7 @@ 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
@@ -26,6 +27,7 @@ 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]
@@ -33,9 +35,20 @@ class OperacoesDashboardController < ApplicationController
@metricas_a = montar([@op_a]) if @op_a
@metricas_b = montar([@op_b]) if @op_b
else
# Operação única: a operação inteira (a data vem dos próprios dados).
# 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.
@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
@@ -46,6 +59,7 @@ 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
@@ -55,6 +69,8 @@ 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
@@ -66,7 +82,9 @@ 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
authorize :dashboard, :operacoes?
# 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?
tabela = params[:operacao].to_s
unless Operacao.valida?(tabela)
@@ -100,6 +118,12 @@ 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

View File

@@ -0,0 +1,12 @@
# 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

View File

@@ -5,6 +5,19 @@ 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
@@ -20,14 +33,13 @@ 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
case resource.role
when 'motorista'
motorista_dashboard_path
else
dashboard_path
end
rota = resource.home_rota
rota ? public_send(rota) : sem_acesso_path
end
def after_sign_out_path_for(resource_or_scope)

View File

@@ -0,0 +1,133 @@
// 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 "15 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"))
// "15 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)
}
}

View File

@@ -0,0 +1,226 @@
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 por :target, CSS puro) ───────────────────────────────
// Abrir, fechar e trocar de folha são do NAVEGADOR: link para `#previa`,
// link para `#sem-previa` e links `?veiculo=X#previa`. Nada disso depende
// deste controller — é o que mantém a prévia viva quando o JS não carrega.
// O que sobra aqui é recarregar a folha depois de uma edição.
previewAberta() {
return this.hasOverlayTarget && this.overlayTarget.matches(":target")
}
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()
}
// O operador pode abrir a prévia DEPOIS de editar: aí o iframe ainda mostra a
// folha antiga. Como quem abre é o `:target`, o gancho é o hashchange.
aoTrocarHash() {
if (this.previewAberta() && this.previewDesatualizado) 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
}
}

View File

@@ -0,0 +1,64 @@
# 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

View File

@@ -0,0 +1,48 @@
# 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

View File

@@ -0,0 +1,26 @@
# 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

View File

@@ -0,0 +1,18 @@
# 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

View File

@@ -19,6 +19,10 @@ 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[

View File

@@ -11,9 +11,15 @@ class ConfiguracaoNotificacao < ApplicationRecord
self.table_name = 'configuracao_notificacoes'
atributo_cifrado :smtp_password, :twilio_auth_token
atributo_cifrado :smtp_password, :twilio_auth_token, :baileys_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'
@@ -28,9 +34,16 @@ class ConfiguracaoNotificacao < ApplicationRecord
validates :email_admin, format: { with: EMAIL_RE }, allow_blank: true
validate :senha_smtp_presente, if: :smtp_ativo?
validates :twilio_account_sid, presence: true, if: :whatsapp_ativo?
validates :twilio_from, presence: true, if: :whatsapp_ativo?
validate :token_twilio_presente, if: :whatsapp_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?
# ── Singleton ───────────────────────────────────────────────
def self.instancia
@@ -102,6 +115,37 @@ 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? &&
@@ -109,7 +153,9 @@ class ConfiguracaoNotificacao < ApplicationRecord
end
def whatsapp_habilitado?
whatsapp_ativo? && credenciais_whatsapp.present?
return false unless whatsapp_ativo?
baileys? ? baileys_pronto? : credenciais_whatsapp.present?
end
# Tudo-ou-nada: nunca mistura o SID do banco com o token do .env (a conta
@@ -162,12 +208,22 @@ 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?

View File

@@ -33,8 +33,18 @@ 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/0826/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/0826/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, -> {
@@ -377,12 +387,19 @@ 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

114
app/models/contato.rb Normal file
View File

@@ -0,0 +1,114 @@
# 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

View File

@@ -92,19 +92,39 @@ 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, -> {
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
valores = contas_gade
valores.nil? ? all : where(account_id: valores)
}
# ── Métodos de classe ────────────────────────────────────────
# Contas aceitas (DB_EXISTING_ACCOUNT_ID), normalizadas. `nil` = sem filtro
# ("all"/vazio); um token vazio ("95907,") inclui também as linhas sem conta.
def self.contas_gade
contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip
return nil if contas.empty? || contas.casecmp?('all')
valores = contas.split(',', -1).map(&:strip)
valores << nil if valores.any?(&:empty?)
valores.uniq
end
# A MESMA condição do scope :da_conta_gade, como fragmento SQL — para as
# queries cruas de Analytics (que montam UNION por operação e não passam pelo
# ActiveRecord) usarem exatamente o mesmo recorte de conta do dashboard.
# Devolve nil quando não há filtro. `apelido` é o alias da tabela na query.
def self.condicao_conta_sql(apelido = table_name)
valores = contas_gade
return nil if valores.nil?
coluna = "#{connection.quote_table_name(apelido)}.account_id"
listadas = valores.compact
partes = []
partes << sanitize_sql_array(["#{coluna} IN (?)", listadas]) if listadas.any?
partes << "#{coluna} IS NULL" if valores.include?(nil)
"(#{partes.join(' OR ')})"
end
# Lista motoristas únicos (para selects, consolidações)
def self.motoristas_ativos(inicio: nil, fim: nil)
base = da_conta_gade

View File

@@ -0,0 +1,125 @@
# 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

View File

@@ -0,0 +1,38 @@
# 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

View File

@@ -0,0 +1,22 @@
# 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

View File

@@ -0,0 +1,85 @@
# 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

View File

@@ -0,0 +1,41 @@
# 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

View File

@@ -78,6 +78,31 @@ 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)
@@ -96,6 +121,14 @@ 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.

View File

@@ -0,0 +1,41 @@
# 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

View File

@@ -0,0 +1,62 @@
# 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

251
app/models/permissao.rb Normal file
View File

@@ -0,0 +1,251 @@
# 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

88
app/models/romaneio.rb Normal file
View File

@@ -0,0 +1,88 @@
# 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

View File

@@ -0,0 +1,68 @@
# 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

View File

@@ -0,0 +1,17 @@
# 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

View File

@@ -6,6 +6,10 @@ 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.
@@ -57,12 +61,113 @@ 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?
admin? || gerente?
pode?('admin.configuracoes')
end
def pode_consolidar?
admin? || gerente? || operador?
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?
end
# Token de login rápido via QR Code (extrato) — Fase 7/8

View File

@@ -0,0 +1,46 @@
# 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

View File

@@ -15,7 +15,15 @@ class ApplicationPolicy
def edit? = update?
def destroy? = user.admin?
# Helpers de papel disponíveis para todas as policies (delegam para o User)
# 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?`.
def admin?
user.admin?
end

View File

@@ -0,0 +1,16 @@
# 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

View File

@@ -1,22 +1,7 @@
# app/policies/configuracao_notificacao_policy.rb
#
# 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.
# Credenciais de SMTP/Twilio e limites de envio.
class ConfiguracaoNotificacaoPolicy < ApplicationPolicy
def show?
admin?
end
def update?
admin?
end
class Scope < Scope
def resolve
scope.all
end
end
def show? = pode?('notificacao.credenciais')
def update? = show?
end

View File

@@ -1,20 +1,10 @@
# app/policies/configuracao_policy.rb
class ConfiguracaoPolicy < ApplicationPolicy
def index?
admin_ou_gerente?
end
def index? = pode?('admin.configuracoes')
def show? = index?
def edit?
admin?
end
def update?
admin?
end
class Scope < Scope
def resolve
scope.all
end
end
# 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?
end

View File

@@ -1,20 +1,42 @@
# app/policies/consolidacao_policy.rb
class ConsolidacaoPolicy < ApplicationPolicy
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?
def index? = pode?('consolidacao.ver')
def arquivadas? = index?
def show? = index?
def create? = pode?('consolidacao.criar')
def new? = create?
# Registrar/estornar pagamento: mesma régua de arquivar/excluir.
def registrar_pagamento? = user.admin? || user.gerente?
def cancelar_pagamento? = registrar_pagamento?
# 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')
private
# Operador não mexe em consolidação finalizada
def record_finalizada_para_operador?
record.respond_to?(:finalizada?) && record.finalizada? && user.operador?
def record_finalizada?
record.respond_to?(:finalizada?) && record.finalizada?
end
end

View File

@@ -0,0 +1,24 @@
# 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

View File

@@ -1,15 +1,17 @@
# app/policies/dashboard_policy.rb
class DashboardPolicy < Struct.new(:user, :dashboard)
def metricas?
user.admin? || user.gerente? || user.operador?
end
#
# 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?
def index?
user.admin? || user.gerente?
end
# 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?
# Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML).
def operacoes?
!user.motorista?
end
def operacoes? = pode?('dashboard.operacoes')
def exportar? = pode?('dashboard.exportar')
def metricas? = pode?('dashboard.metricas')
end

View File

@@ -1,16 +1,11 @@
# app/policies/edicao_lancamento_policy.rb
#
# 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).
# Policy headless (`authorize :edicao_lancamento`). Escreve na API do SimpliRoute:
# altera o dado de ORIGEM do rastreio, então é chave própria.
class EdicaoLancamentoPolicy < ApplicationPolicy
# ⚠️ 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?
def show? = pode?('admin.edicao_lancamento')
def buscar? = show?
def carregar? = show?
def atualizar? = show?
def historico? = show?
end

View File

@@ -0,0 +1,25 @@
# 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

View File

@@ -0,0 +1,3 @@
# app/policies/grupo_contato_policy.rb — ver ContatoPolicy.
class GrupoContatoPolicy < ContatoPolicy
end

View File

@@ -0,0 +1,13 @@
# 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

View File

@@ -0,0 +1,24 @@
# 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

View File

@@ -1,9 +1,7 @@
# app/policies/planilha_simpliroute_policy.rb
# app/policies/planilha_simpli_route_policy.rb
#
# 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).
# Policy headless (`authorize :planilha_simpli_route`).
class PlanilhaSimpliRoutePolicy < ApplicationPolicy
def show? = admin_ou_gerente?
def baixar? = admin_ou_gerente?
def show? = pode?('admin.planilha_simpli_route')
def baixar? = show?
end

View File

@@ -0,0 +1,21 @@
# 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

View File

@@ -1,44 +1,35 @@
# app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
def index?
admin_ou_gerente?
end
def index? = pode?('admin.usuarios')
def show?
admin? || record == user
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 create?
admin?
end
def create? = pode?('admin.usuarios_gerenciar')
def new? = create?
def new?
create?
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 update?
admin? || record == user
end
def destroy? = create? && record != user
def toggle_ativo? = create? && 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.
# 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
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.gerente?
elsif user.pode?('admin.usuarios')
scope.where.not(role: :admin)
else
scope.where(id: user.id)

View File

@@ -0,0 +1,19 @@
# 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

View File

@@ -0,0 +1,9 @@
# 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

View File

@@ -0,0 +1,189 @@
# 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

View File

@@ -2,12 +2,20 @@
#
# Núcleo de dados do "Dashboard de Operações".
#
# 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).
# 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.
#
# 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
@@ -36,15 +44,30 @@ module Analytics
@tabelas.map { |t| Operacao.label(t) }.join(', ')
end
# Linhas após o cross-filter — base de TODAS as agregações/KPIs.
def linhas
@linhas ||= begin
# 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
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
@@ -103,6 +126,50 @@ 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
{
@@ -206,6 +273,27 @@ 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
@@ -247,35 +335,33 @@ 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 ultimo r
FROM #{rastreio} r
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
WHERE r.rn = 1#{filtro_periodo(conn)}
WHERE r.reference_id IS NOT NULL#{conta ? " AND #{conta}" : ''}#{filtro_periodo(conn)}
SQL
end
# 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
# 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'] }
end
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:

View File

@@ -0,0 +1,50 @@
# 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

View File

@@ -0,0 +1,135 @@
# 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/0826/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

View File

@@ -0,0 +1,65 @@
# 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

View File

@@ -0,0 +1,196 @@
# 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 0114/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

View File

@@ -0,0 +1,110 @@
# 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

View File

@@ -0,0 +1,195 @@
# 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

View File

@@ -0,0 +1,31 @@
# 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

View File

@@ -0,0 +1,128 @@
# 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

View File

@@ -0,0 +1,159 @@
# 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

View File

@@ -0,0 +1,90 @@
# 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

View File

@@ -1,9 +1,11 @@
# app/services/notificacao/teste_whatsapp.rb
#
# 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.
# 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.
module Notificacao
class TesteWhatsapp
MENSAGENS = {
@@ -26,7 +28,8 @@ module Notificacao
def initialize(config = ConfiguracaoNotificacao.instancia, destino: nil)
@config = config
@destino = ConfiguracaoNotificacao.canal(destino.presence || config.whatsapp_admin)
@numero = ConfiguracaoNotificacao.normalizar_telefone(destino.presence || config.whatsapp_admin)
@destino = @numero && ConfiguracaoNotificacao.canal(@numero)
end
def executar
@@ -35,6 +38,21 @@ 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 ' \
@@ -63,11 +81,9 @@ 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, a integração com o Twilio está funcionando.\n" \
"Se você recebeu esta mensagem, o envio de WhatsApp está funcionando.\n" \
"Enviado em #{Time.current.strftime('%d/%m/%Y às %H:%M')}."
end
end

View File

@@ -0,0 +1,78 @@
# 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 0114/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

View File

@@ -0,0 +1,51 @@
# 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

View File

@@ -13,10 +13,12 @@
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:' }
@@ -48,10 +50,70 @@ class NotificacaoService
def notificar(cm, evento)
user = motorista_de(cm)
return unless user
enviar_whatsapp(user, cm, evento) if @config.whatsapp_habilitado?
enviar_email(user, cm, evento) if @config.email_habilitado? && user.email.present?
# 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)
@@ -59,34 +121,57 @@ class NotificacaoService
end
def enviar_whatsapp(user, cm, evento)
# 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)
# 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)
return if destino.blank?
credenciais = @config.credenciais_whatsapp
return if credenciais.blank? || credenciais[:from].blank?
texto = mensagem(user, cm, evento)
envio = registrar_envio(evento, canal: 'whatsapp', destino: destino, user: user, corpo: texto)
Notificacao::ClienteTwilio
.montar(credenciais[:sid], credenciais[:token])
.messages.create(from: credenciais[:from], to: destino, body: mensagem(user, cm, evento))
Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}")
rescue StandardError => e
Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{e.class}: #{e.message}")
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
end
def enviar_email(user, cm, evento)
ConsolidacaoMailer
.public_send(EVENTOS.fetch(evento)[:mailer], user, @consolidacao, cm)
.deliver_later
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!
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" \
@@ -96,10 +181,6 @@ class NotificacaoService
"#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
end
# 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
# Ver Notificacao::Formatador — implementação única.
def moeda(valor) = Notificacao::Formatador.moeda(valor)
end

View File

@@ -9,16 +9,15 @@ module Pdf
PRETO = '0A0A0A'
CINZA = '6B7280'
# 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')
# 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
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
registrar_fonte_utf8(@pdf)
end
def render
@@ -30,24 +29,6 @@ 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