Compare commits

..

60 Commits

Author SHA256 Message Date
5e75122716 Merge pull request 'teste' (#12) from teste into main
Reviewed-on: #12
2026-08-28 17:55:38 -03:00
74dc16e508 , Correçoes na busca da API para ver os ultimos planos 2026-08-28 17:44:42 -03:00
2bdad2a45b , Correçoes na busca da API para ver os ultimos planos 2026-08-28 17:24:05 -03:00
3e51d0e03b , Correçoes na busca da API para ver os ultimos planos 2026-08-28 17:03:37 -03:00
bfa474262f , Correçoes UX consolidaçoes e de backa-end 2026-08-28 16:38:51 -03:00
b6fe6f796f , Correçoes UX romaneios 2026-08-28 16:20:29 -03:00
0e57e48214 , Correçoes UX 2026-08-28 16:14:38 -03:00
19a0c7a302 , Teste e correçoes 2026-08-28 13:13:10 -03:00
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
18239b133b Merge pull request 'teste' (#11) from teste into main
Reviewed-on: #11
2026-08-24 11:32:28 -03:00
7b3ec3e407 Documenta no README as correções do dashboard (20-21/08)
Registra o arco do insucesso no pagamento, que não estava documentado: a
mudança da base do dashboard de `pagas` para `atendidas` (completed +
failed), a troca do eixo de planned_date para checkout, o mesmo ajuste no
painel do motorista, a aba Consolidado no ranking e a correção da barra.

Inclui a conferência com dados reais do ambiente de teste — a aba
Consolidado fecha em R$ 72.508,00 / 3.858 entregas, idêntico aos KPIs do
topo — e deixa explícito que a suíte continua pendente de execução, com o
roteiro para rodar no Docker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:23:24 -03:00
e9db6b7164 Correção da barra do ranking de motoristas
A barra era proporcional à QUANTIDADE de entregas, mas as duas abas do card
são ordenadas por VALOR. Na aba Estimado dá no mesmo (valor = qtd × preço),
mas na Consolidada bônus/retirada/termo mudam o preço unitário: com os dados
do teste a barra do 3º (290 entregas, R$ 5.250) saía MAIOR que a do 2º
(261 entregas, R$ 5.260), contradizendo a ordem do ranking. Invertia em três
pontos da lista.

Passa a escalar pelo valor, que é o número que ordena — as barras encurtam
sempre de cima para baixo. Mantida a guarda de divisão por zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:21:08 -03:00
4965931c13 correção de bugs na area de Dashbord 2026-08-21 01:12:47 -03:00
cebd6e2dcd correção de bugs 2026-08-20 15:48:47 -03:00
e3d4546c3c Adição da função de configuração de email e whatts 2026-08-11 23:42:00 -03:00
c86bae66a4 Adição da função de configuração de email e whatts 2026-08-11 17:02:45 -03:00
e530815377 Adição da função de configuração de email e whatts 2026-08-11 13:31:22 -03:00
dd5621a215 Merge pull request 'teste' (#10) from teste into main
Reviewed-on: #10
2026-07-22 11:40:17 -03:00
e8f470f878 Correção do relatório que é gerado para completo 2026-07-22 11:35:59 -03:00
d3667ac48a Correção do relatório que é gerado para envio gade 2026-07-22 10:01:41 -03:00
def8a8314c Correção de responsividade e atualização do read.me 2026-07-22 01:37:20 -03:00
3dbb65efd2 Correção de responsividade 2026-07-22 01:21:32 -03:00
dc1b5c1b41 Correção de responsividade geral 2026-07-22 01:04:22 -03:00
84d6607d63 Correção da edição da função de editar 2026-07-21 16:41:12 -03:00
033e083ec5 Correção do função de editar 2026-07-21 16:33:21 -03:00
faa5226e95 Correção do função de editar lançamento para quer consiga listar todas 2026-07-21 16:23:25 -03:00
2b0a3b52ef Correção do função de editar lançamento para quer consiga listar todas 2026-07-21 11:49:43 -03:00
34aa563934 Atualização do função de editar lançamento para quer consiga listar todas 2026-07-21 11:36:29 -03:00
268 changed files with 22153 additions and 55792 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
@@ -45,6 +67,15 @@ SECRET_KEY_BASE=gere_com_rails_secret_e_cole_aqui
# deixe vazio/false para não forçar https em localhost.
FORCE_SSL=true
# ══════════════════════════════════════════════════════════════
# NOTIFICAÇÕES (WhatsApp e e-mail)
#
# ⚠️ Estas variáveis são apenas FALLBACK. A configuração preferencial fica no
# BANCO e é editada pelo ADM em Configurações → Notificações e E-mail
# (/admin/configuracao_notificacao), valendo sem reiniciar o container.
# Enquanto a tela não for preenchida e ativada, valem os valores abaixo.
# ══════════════════════════════════════════════════════════════
# ── Twilio — WhatsApp (opcional, Fase 8) ─────────────────────
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
@@ -57,6 +88,42 @@ SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_DOMAIN=gade.com.br
# ── Criptografia dos segredos gravados pela tela (opcional) ──
# A senha do SMTP e o token do Twilio salvos no banco são cifrados com uma
# chave derivada do SECRET_KEY_BASE. Se você trocar o SECRET_KEY_BASE, esses
# dois valores viram ilegíveis (o sistema volta a usar o .env e a tela avisa
# para redigitar). Defina NOTIFICACAO_SECRET com uma string longa e FIXA para
# desacoplar os segredos da rotação do SECRET_KEY_BASE.
# ⚠️ Se mudar depois de já ter salvo, os valores gravados também se perdem.
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

2111
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

@@ -0,0 +1,108 @@
# app/controllers/admin/configuracao_notificacoes_controller.rb
#
# Tela única (singleton) de SMTP + WhatsApp. Só admin — ver
# ConfiguracaoNotificacaoPolicy.
class Admin::ConfiguracaoNotificacoesController < ApplicationController
before_action :set_config
CAMPOS = %i[
smtp_ativo smtp_address smtp_port smtp_username smtp_password smtp_domain
smtp_autenticacao remetente_email remetente_nome
email_notificacoes_ativo email_admin
whatsapp_ativo whatsapp_provedor whatsapp_intervalo_segundos whatsapp_admin
twilio_account_sid twilio_auth_token twilio_from
baileys_url baileys_token
].freeze
def show
authorize @config
end
# PATCH /admin/configuracao_notificacao
#
# Os botões "enviar teste" são submits DESTE MESMO form, diferenciados por
# params[:acao]. Assim o teste sempre usa o que está na tela — evita o
# clássico "digitei a senha nova, cliquei em testar, e ele testou a antiga".
def update
authorize @config
antes = resumo
unless @config.update(config_params)
flash.now[:alert] = @config.errors.full_messages.to_sentence
return render(:show, status: :unprocessable_entity)
end
auditar!(antes)
case params[:acao]
when 'testar_email'
redirecionar(Notificacao::TesteEmail.new(@config, usuario: current_user).executar)
when 'testar_whatsapp'
redirecionar(Notificacao::TesteWhatsapp.new(@config).executar)
else
redirect_to admin_configuracao_notificacao_path,
notice: 'Configurações de notificação salvas.'
end
end
private
def set_config
@config = ConfiguracaoNotificacao.instancia
end
def redirecionar(resultado)
redirect_to admin_configuracao_notificacao_path, **resultado.para_flash
end
def config_params
permitidos = params.require(:configuracao_notificacao).permit(*CAMPOS)
# Campo de senha em branco significa "mantenha a que já está gravada" — a
# 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
# ⚠️ NUNCA colocar senha ou token aqui: AuditoriaLog#dados_novos é exibido em
# /admin/auditoria_logs, que gerente também acessa. Só o booleano de
# "está definida ou não".
def resumo
{
smtp_ativo: @config.smtp_ativo,
smtp_address: @config.smtp_address,
smtp_port: @config.smtp_port,
smtp_username: @config.smtp_username,
smtp_domain: @config.smtp_domain,
smtp_autenticacao: @config.smtp_autenticacao,
remetente_email: @config.remetente_email,
remetente_nome: @config.remetente_nome,
email_admin: @config.email_admin,
email_notificacoes_ativo: @config.email_notificacoes_ativo,
whatsapp_ativo: @config.whatsapp_ativo,
whatsapp_provedor: @config.whatsapp_provedor,
whatsapp_intervalo: @config.whatsapp_intervalo_segundos,
twilio_account_sid: @config.twilio_account_sid,
twilio_from: @config.twilio_from,
whatsapp_admin: @config.whatsapp_admin,
baileys_url: @config.baileys_url,
smtp_password_definida: @config.smtp_password?,
twilio_auth_token_definido: @config.twilio_auth_token?,
baileys_token_definido: @config.baileys_token?
}
end
def auditar!(antes)
AuditoriaLog.registrar(
user: current_user,
acao: 'editar_notificacoes',
entidade: 'ConfiguracaoNotificacao',
entidade_id: @config.id,
dados_anteriores: antes,
dados_novos: resumo,
request: request
)
end
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

@@ -22,45 +22,43 @@ class Admin::EdicaoLancamentosController < ApplicationController
@motivos = motivos_seguros
end
# GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com o estado atual.
# GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com TODOS os lançamentos
# daquela NF.
#
# ⚠️ Uma NF pode ter mais de um lançamento: quando o plano é duplicado no
# SimpliRoute nasce uma visita nova (outro tracking_id) com a mesma NF, e a
# antiga — a que tem o motorista/checkout — continua existindo. Por isso a
# busca devolve a LISTA e o ADM escolhe qual editar; assumir "1 NF = 1
# lançamento" escondia justamente o lançamento antigo.
#
# Com um único lançamento já vem o card montado, para não custar um segundo
# round-trip no caso comum.
#
# Aceita ?de=&ate= para varrer um período (a API só filtra por UM dia, então
# varremos dia a dia). Sem período, usa as datas que o espelho conhece.
def buscar
authorize :edicao_lancamento
nf = params[:nf].to_s.strip
return render_erro('Informe o número da NF.') if nf.blank?
entrega = Entrega.por_nf(nf).first
return render_erro("NF #{nf} não encontrada na base de rastreio.") if entrega.nil?
entregas = Entrega.por_nf(nf).to_a
datas = datas_de_busca(entregas)
return render_erro(sem_datas_msg(nf, entregas)) if datas.empty?
id = client.resolver_id(entrega)
visita = client.visita(id)
# Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}.
detalhe = client.detalhe_visita(id)
falhas = []
ocorrencias = ocorrencias_da_nf(nf, entregas, datas, falhas)
unica = ocorrencias.first if ocorrencias.one? && ocorrencias.first[:id].present?
render json: {
ok: true,
visita: {
id: id,
nf: visita['reference'],
titulo: visita['title'],
endereco: visita['address'],
status: visita['status'],
checkout_observation: visita['checkout_observation'],
checkout_comment: visita['checkout_comment'],
notes: visita['notes'],
checkout_time: visita['checkout_time'],
checkout_latitude: visita['checkout_latitude'],
checkout_longitude: visita['checkout_longitude'],
planned_date: visita['planned_date'],
contato: visita['contact_name'],
telefone: visita['contact_phone'],
# Galeria unificada — TODAS as fotos do lançamento, de todas as fontes,
# cada uma etiquetada para o ADM saber o que está conferindo.
fotos: fotos_do_lancamento(entrega, visita, detalhe),
motorista: entrega.driver,
veiculo: entrega.vehicle
},
motivos: motivos_seguros
ok: true,
ocorrencias: ocorrencias,
visita: (unica && payload_seguro(unica, entregas, falhas)),
periodo: { de: datas.first, ate: datas.last, dias: datas.size },
# Dias que a API não respondeu. Vão para a tela: uma busca que devolve
# menos do que existe TEM que dizer que devolveu menos.
falhas: falhas,
motivos: motivos_seguros
}
rescue SimpliRoute::NotFound => e
render_erro(e.message)
@@ -68,6 +66,23 @@ class Admin::EdicaoLancamentosController < ApplicationController
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
end
# GET /admin/edicao_lancamento/carregar?visit_id=871407488&tracking_id=SR...
# Monta o card de UM lançamento escolhido na lista de ocorrências.
def carregar
authorize :edicao_lancamento
id = params[:visit_id].to_s.strip
return render_erro('Lançamento não identificado.') if id.blank?
entrega = Entrega.find_by(tracking_id: params[:tracking_id]) if params[:tracking_id].present?
render json: { ok: true, visita: payload_visita(id, entrega), motivos: motivos_seguros }
rescue SimpliRoute::NotFound => e
render_erro(e.message)
rescue SimpliRoute::Error => e
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
end
# PATCH /admin/edicao_lancamento/atualizar
# HTML: fluxo de form clássico (fallback). JSON: salvar-por-campo do card
# (click-to-edit) — o fetch manda visit_id + só o campo editado.
@@ -103,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) }
@@ -142,10 +166,226 @@ 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
# Máximo de dias varridos numa busca por período. A API só filtra por UM dia,
# então cada dia é uma chamada — com `search` cada uma custa ~0,6 s e elas vão
# em lotes paralelos, o que torna dois meses viável; o teto existe para a tela
# não virar uma varredura sem fim.
MAX_DIAS_VARREDURA = 62
# Quais dias consultar na API:
# • com ?de=/?ate= — o período pedido (o SimpliRoute duplica plano para
# outra data, e aí o espelho não tem como saber qual é);
# • sem período — as datas que o espelho conhece (barato: 1 ou 2 chamadas).
def datas_de_busca(entregas)
de = data_param(:de)
ate = data_param(:ate)
if de || ate
inicio, fim = [de || ate, ate || de].minmax
(inicio..fim).first(MAX_DIAS_VARREDURA)
else
entregas.filter_map { |e| e.planned_date&.to_date }.uniq.sort
end
end
def data_param(chave)
valor = params[chave].to_s.strip
return nil if valor.blank?
Date.parse(valor)
rescue Date::Error
nil
end
# Sem datas não há o que consultar — e o motivo muda a saída para o ADM.
def sem_datas_msg(nf, entregas)
if entregas.empty?
"NF #{nf} não encontrada na base de rastreio. Informe um período para procurar direto no SimpliRoute."
else
"NF #{nf} está na base de rastreio mas sem data planejada. Informe um período para procurar na API."
end
end
# Todos os lançamentos da NF, cruzando as DUAS fontes:
# • espelho de rastreio (Entrega) — traz motorista/veículo;
# • API do SimpliRoute, nas datas varridas — pega a visita duplicada que a
# sincronização ainda não trouxe para o espelho.
def ocorrencias_da_nf(nf, entregas, datas, falhas)
trackings = entregas.map { |e| e.tracking_id.to_s }
do_dia = visitas_dos_dias(datas, nf, falhas)
visitas = do_dia.select { |v| v['reference'].to_s == nf || trackings.include?(v['tracking_id'].to_s) }
.uniq { |v| v['id'] }
visitas = com_tracking_id(visitas)
achadas = visitas.map { |v| ocorrencia_da_api(v, entrega_de(entregas, v['tracking_id'])) }
# Lançamento que existe no espelho mas não apareceu na API (data sem plano,
# visita removida): entra na lista como não editável, para o ADM ver que
# ele existe em vez de sumir silenciosamente — que é o bug que estamos
# corrigindo.
vistos = visitas.map { |v| v['tracking_id'].to_s }
orfas = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) }
.map { |e| ocorrencia_do_espelho(e, datas.include?(e.planned_date&.to_date)) }
# Mais recente primeiro (a data vem como texto ISO da API e como timestamp
# do espelho — os 10 primeiros caracteres normalizam as duas).
(achadas + orfas).sort_by { |o| [o[:data].to_s[0, 10], o[:id].to_i] }.reverse
end
# Dias consultados de uma vez. Cada chamada abre a própria conexão HTTP (ver
# SimpliRoute::Client#requisicao) e não toca o banco, então dá para
# paralelizar em lotes; o lote pequeno evita martelar a API do SimpliRoute.
MAX_PARALELO_VARREDURA = 6
# Passa a NF como `search` — sem isso cada dia baixaria ~3,8 MB (o dia inteiro)
# e um mês seria inviável. Ver a nota em SimpliRoute::Client#visitas_da_data.
def visitas_dos_dias(datas, nf, falhas)
mutex = Mutex.new
datas.each_slice(MAX_PARALELO_VARREDURA).flat_map do |lote|
lote.map { |data| Thread.new { visitas_do_dia(data, nf, falhas, mutex) } }.flat_map(&:value)
end
end
# Um dia que a API não responde NÃO pode derrubar a busca inteira: registra a
# falha e segue com os outros dias. (Foi assim que uma NF com visitas em duas
# datas passou a não devolver nada em vez de devolver o que deu certo.)
def visitas_do_dia(data, nf, falhas, mutex)
client.visitas_da_data(data, busca: nf)
rescue SimpliRoute::Error => e
mutex.synchronize { falhas << { data: data.strftime('%d/%m/%Y'), erro: e.message } }
[]
end
# Abrir o card do lançamento único é conveniência: se falhar, a lista ainda
# tem que aparecer.
def payload_seguro(ocorrencia, entregas, falhas)
payload_visita(ocorrencia[:id], entrega_de(entregas, ocorrencia[:tracking_id]))
rescue SimpliRoute::Error => e
falhas << { data: ocorrencia[:data].to_s[0, 10], erro: "não foi possível abrir o lançamento: #{e.message}" }
nil
end
def entrega_de(entregas, tracking_id)
return nil if tracking_id.blank?
entregas.find { |e| e.tracking_id.to_s == tracking_id.to_s }
end
# Quantas visitas da lista vale a pena detalhar (1 GET cada). Uma NF com mais
# que isso é dado estranho, não plano duplicado.
MAX_DETALHES_LISTA = 5
# A lista-por-data nem sempre traz o `tracking_id`, e é ele que liga a visita
# ao espelho — de onde vêm motorista e veículo. Sem esse casamento a NF
# duplicada aparece com as duas linhas sem motorista, que é exatamente o dado
# que distingue uma da outra. Quando faltar, busca a visita completa.
def com_tracking_id(visitas)
visitas.each_with_index.map do |visita, i|
next visita if visita['tracking_id'].present? || i >= MAX_DETALHES_LISTA
detalhar(visita)
end
end
# Best-effort: se o detalhe falhar, segue com o que a lista deu.
def detalhar(visita)
client.visita(visita['id']).presence || visita
rescue SimpliRoute::Error
visita
end
# Resumo de um lançamento para a lista de escolha (não carrega fotos/detalhe —
# isso só acontece quando o ADM abre um).
def ocorrencia_da_api(visita, entrega)
{
id: visita['id'],
tracking_id: visita['tracking_id'],
titulo: visita['title'],
endereco: visita['address'],
status: visita['status'],
data: visita['planned_date'],
checkout_time: visita['checkout_time'],
motorista: entrega&.driver,
veiculo: entrega&.vehicle,
editavel: true,
# Sem linha no espelho não há como saber motorista/veículo: a API não
# devolve esses campos na visita. O ADM diferencia pelo status/checkout.
no_painel: entrega.present?,
aviso: entrega ? nil : 'Ainda não sincronizado no painel — motorista e veículo indisponíveis'
}
end
def ocorrencia_do_espelho(entrega, data_varrida)
{
id: nil,
tracking_id: entrega.tracking_id,
titulo: entrega.local,
endereco: entrega.address,
status: entrega.status,
data: entrega.planned_date,
checkout_time: entrega.checkout,
motorista: entrega.driver,
veiculo: entrega.vehicle,
editavel: false,
no_painel: true,
aviso: (if data_varrida
'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar'
else
'Fora do período consultado — inclua a data deste lançamento no período para poder editá-lo'
end)
}
end
# Estado completo de UMA visita para o card (inclui a galeria de fotos, que
# custa uma chamada extra ao POD). `entrega` pode ser nil quando a visita
# ainda não sincronizou no espelho.
def payload_visita(id, entrega)
visita = client.visita(id)
# Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}.
detalhe = client.detalhe_visita(id)
{
id: id,
tracking_id: visita['tracking_id'],
nf: visita['reference'],
titulo: visita['title'],
endereco: visita['address'],
status: visita['status'],
checkout_observation: visita['checkout_observation'],
checkout_comment: visita['checkout_comment'],
notes: visita['notes'],
checkout_time: visita['checkout_time'],
checkout_latitude: visita['checkout_latitude'],
checkout_longitude: visita['checkout_longitude'],
planned_date: visita['planned_date'],
contato: visita['contact_name'],
telefone: visita['contact_phone'],
# Galeria unificada — TODAS as fotos do lançamento, de todas as fontes,
# cada uma etiquetada para o ADM saber o que está conferindo.
fotos: fotos_do_lancamento(entrega, visita, detalhe),
motorista: entrega&.driver,
veiculo: entrega&.vehicle
}
end
# Monta o hash de PATCH só com os campos que vieram no form E que realmente
# mudaram em relação ao estado atual — PATCH mínimo, sem sobrescrever à toa.
def mudancas(anterior)

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,482 @@
# 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.
# FileUtils chegava aqui de carona no que o Rails carrega. Explícito porque o
# upload do logo depende dele: sem o require, o dia em que o Rails parar de
# carregá-lo o erro é um NameError no meio da gravação.
require 'fileutils'
class Admin::RomaneiosController < ApplicationController
include Auditavel
# ⚠️ TODA ACTION DE COLEÇÃO PRECISA ENTRAR NESTE `except`. `carregar_romaneio`
# faz `Romaneio.find(params[:id])`, e numa rota de coleção não existe `:id` —
# o find levanta RecordNotFound, que o Rails traduz em **404**. Não é erro de
# rota: o roteador casa certinho e a action simplesmente nunca roda.
#
# Foi o bug de 28/08/2026: `atualizar_logo` foi adicionada ao `collection` do
# routes.rb mas esquecida aqui, e clicar em "Trocar logo" devolvia
# `POST /admin/romaneios/atualizar_logo 404 (Not Found)`. Como o 404 parece
# "rota errada", a busca foi para todo lado menos para cá — e o sintoma que o
# operador via era outro ("subi o logo e o PDF não mudou"), porque o upload
# nunca chegava a acontecer.
before_action :carregar_romaneio, except: %i[index create importar_planilha atualizar_logo]
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
# Os planos para o operador ESCOLHER em vez de adivinhar a data.
#
# Sem período: os 5 mais recentes — a ferramenta é usada logo depois de
# lançar uma operação, então o que ele quer está sempre entre os últimos, e
# o caso comum fica em zero clique.
# Com período: TODOS os planos da faixa. É assim que se alcança um plano
# antigo sem saber o dia dele — e é o mesmo recorte da tela do SimpliRoute,
# para o operador não ter que traduzir nada entre as duas.
@plano_inicio = params[:plano_inicio].presence
@plano_fim = params[:plano_fim].presence
@planos = SimpliRoute::Client.new.planos(limite: 5, inicio: @plano_inicio, fim: @plano_fim)
@planos_filtrados = @plano_inicio.present? && @plano_fim.present?
# O logo escolhido sumiu do disco? A tela precisa dizer — foi exatamente
# assim que o PDF saiu com o logo antigo sem ninguém entender (28/08/2026).
_, @logo_problema = Pdf::RomaneioPdf.logo_configurado
# Nome ORIGINAL do logo escolhido (a descricao), para a tela mostrar o mesmo
# que o operador enviou; nil = ainda usando o logo da GADE do repositório.
@logo_atual = Configuracao.find_by(chave: 'romaneio_logo')&.descricao
end
def show
authorize @romaneio
@veiculos = @romaneio.romaneio_veiculos.ordenados.to_a
@veiculo = veiculo_selecionado
@linhas = @romaneio.linhas_vigentes.do_veiculo(@veiculo).to_a if @veiculo
@removidas = @romaneio.linhas_removidas.to_a
@colunas_ausentes = colunas_ausentes_da_operacao
# Mesma lista da tela de criação: o vínculo manual da operação acontece aqui
# também, porque é aqui que se descobre que APARELHO saiu vazio.
@operacoes_agrupadas = Operacao.agrupadas_por_mes
# A prévia aberta é ESTADO NA URL (?previa=1), não estado de navegador. Ver o
# comentário em _previa.html.erb: hash (#previa) não sobrevive ao Turbo e
# checkbox não sobrevive à troca de folha.
@previa = params[:previa].present?
end
# Caminho A: busca o plano no SimpliRoute (API, com queda para o espelho).
def create
authorize Romaneio
@romaneio = preparar_romaneio
return if performed?
# `@plano_escolhido` vem de #preparar_romaneio quando o operador clicou num
# plano da lista; sem ele o caminho continua sendo por data.
# Os DOIS: a data já foi resolvida em #preparar_romaneio (não paga a chamada
# de novo) e ainda destrava a queda para o espelho local se a API cair no
# meio; o plano é quem delimita as rotas.
plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date, plano: @plano_escolhido)
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.
# ⚠️ O FORMATO É DECIDIDO PELO CONTEÚDO DO ARQUIVO, não pelo `content_type`
# que o navegador manda. O content_type é entrada do usuário: Windows e alguns
# navegadores mandam `application/octet-stream` para um PNG perfeitamente
# válido, e a versão antiga (`LOGO_TIPOS[arquivo.content_type]`) RECUSAVA esse
# arquivo dizendo "precisa ser PNG ou JPG" — para um PNG. Assinatura de arquivo
# não mente.
#
# A lista é curta porque quem manda aqui é o Prawn: ele embute **PNG e JPEG**,
# e só. Formato que ele não embute não pode ser aceito "para ver no que dá" —
# viraria um PDF quebrado na hora de imprimir, ou um logo que some sem aviso.
# Os outros formatos são RECONHECIDOS mesmo assim, para a mensagem poder dizer
# o que a pessoa mandou e o que fazer, em vez de repetir "PNG ou JPG".
ASSINATURAS = [
["\x89PNG\r\n\x1a\n".b, '.png', 'PNG', true],
["\xFF\xD8\xFF".b, '.jpg', 'JPEG', true],
['RIFF'.b, nil, 'WebP', false], # confirmado abaixo pelo 'WEBP'
['GIF8'.b, nil, 'GIF', false],
['BM'.b, nil, 'BMP', false],
['%PDF'.b, nil, 'PDF', false],
['<?xml'.b, nil, 'SVG', false],
['<svg'.b, nil, 'SVG', false]
].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?
# `params[:logo]` só é um arquivo se o <form> for multipart. Sem isto, um
# form mal montado manda uma String e a linha seguinte estoura com
# NoMethodError — página 500 em vez de "escolha o arquivo".
unless arquivo.respond_to?(:read) && arquivo.respond_to?(:size)
return redirect_to(admin_romaneios_path,
alert: 'O arquivo não chegou. Escolha o logo e envie de novo.')
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
# Lê tudo UMA vez: o mesmo conteúdo serve para identificar o formato e para
# gravar. Reler o upload depois de `read` devolveria vazio.
conteudo = arquivo.read.to_s.b
extensao, formato, aceito = identificar_imagem(conteudo)
if extensao.nil?
recado = if formato
"O arquivo é #{formato}, e o PDF só embute PNG ou JPG. " \
'Salve o logo como PNG (qualquer editor exporta) e envie de novo.'
else
'Não reconheci esse arquivo como imagem. O logo precisa ser PNG ou JPG.'
end
return redirect_to(admin_romaneios_path, alert: recado)
end
# `aceito` existe para o dia em que alguém adicionar conversão: hoje só
# chega aqui o que o Prawn embute.
return redirect_to(admin_romaneios_path, alert: 'Formato não suportado.') unless aceito
# 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
# ⚠️ A GRAVAÇÃO PODE FALHAR, E FALHAR AQUI NÃO É "bug do Rails": `storage/`
# mora no share SMB do NAS, onde permissão de pasta se perde na cópia (o
# mesmo motivo que fez o bin/docker-boot ser chamado com `bash` explícito).
# Sem este rescue, um EACCES vira a página 500 crua na cara do operador, sem
# dizer o que houve nem o que fazer — foi o que aconteceu em 28/08/2026 ao
# clicar em "Trocar logo".
begin
FileUtils.mkdir_p(destino)
File.binwrite(destino.join(nome), conteudo)
rescue SystemCallError => e
Rails.logger.error("[romaneio] falha ao gravar logo em #{destino}: #{e.class}: #{e.message}")
return redirect_to(admin_romaneios_path,
alert: "Não consegui gravar o logo em #{destino} (#{e.class}). " \
'A pasta existe e o container tem permissão de escrita nela? ' \
'O erro completo está no log do container.')
end
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
# ── Plano escolhido na lista (caminho normal desde 28/08/2026) ──────────
# O plano resolve de uma vez os três campos que antes eram digitados: a
# DATA (perguntada à API, porque a janela do plano não a revela), o NOME do
# plano (que ia colado à mão e é o que descobre a operação do mês) e as
# rotas, que delimitam o romaneio quando dois planos caem no mesmo dia.
@plano_escolhido = nil
data = params[:planned_date].presence
if params[:plano_id].present?
@plano_escolhido = SimpliRoute::Client.new.plano(params[:plano_id])
if @plano_escolhido.nil?
redirect_to admin_romaneios_path,
alert: 'Plano não encontrado no SimpliRoute — recarregue a página e escolha de novo.'
return nil
end
# A data REAL do plano, não a janela dele. Sem isso o romaneio nasceria
# com `planned_date` errado e o "Reimportar" (que é por data) buscaria o
# dia errado depois.
resolvida = Romaneios::PlanoDoDia.new(plano: @plano_escolhido).data_resolvida
if resolvida.nil?
redirect_to admin_romaneios_path,
alert: "Não consegui descobrir o dia do plano \"#{@plano_escolhido['name']}\". " \
'Informe a data ou envie a planilha do plano (.xlsx).'
return nil
end
data = resolvida.to_s
params[:rotulo_plano] = @plano_escolhido['name'] if params[:rotulo_plano].blank?
end
if data.blank?
redirect_to admin_romaneios_path, alert: 'Escolha um plano ou informe a data.'
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
# Formato pela ASSINATURA do arquivo. Devolve [extensao, nome_do_formato,
# aceito?] — com nome preenchido mesmo para o que não serve, para a mensagem
# poder dizer "é WebP" em vez de "não é PNG ou JPG".
def identificar_imagem(conteudo)
achado = ASSINATURAS.find { |assinatura, _, _, _| conteudo.start_with?(assinatura) }
return [nil, nil, false] if achado.nil?
_, extensao, formato, aceito = achado
# RIFF é contêiner (WebP, WAV, AVI): o que diz que é WebP são os bytes 8..11.
return [nil, nil, false] if formato == 'WebP' && conteudo[8, 4] != 'WEBP'
[extensao, formato, aceito]
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,12 +278,43 @@ 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.'
end
# POST /consolidacoes/arquivar_em_lote — arquiva as consolidações marcadas na
# lista. Mesma regra do arquivar de uma só (nenhuma trava extra): arquivar é
# REVERSÍVEL (Arquivadas → Reativar), então o lote não precisa de confirmação
# mais dura do que a de uma — o que ele precisa é dizer QUANTAS foram.
#
# Ignora em silêncio id que não está mais ativo (alguém arquivou pela outra
# aba enquanto a lista estava aberta): o resultado que o operador queria — "essas
# saem da lista" — já é o que está na tela, e erro aqui só assustaria à toa.
def arquivar_em_lote
authorize Consolidacao, :arquivar?
ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
if ids.empty?
return redirect_back fallback_location: consolidacoes_path,
alert: 'Marque ao menos uma consolidação para arquivar.'
end
alvos = Consolidacao.ativas.where(id: ids).to_a
alvos.each do |c|
c.arquivar!(current_user)
auditar!(:arquivar, c, dados_novos: { em_lote: true, total_do_lote: alvos.size })
end
aviso = case alvos.size
when 0 then 'Nenhuma das consolidações marcadas continuava ativa.'
when 1 then "1 consolidação arquivada. Você pode restaurar em \"Arquivadas\"."
else "#{alvos.size} consolidações arquivadas. Você pode restaurar em \"Arquivadas\"."
end
redirect_back fallback_location: consolidacoes_path, notice: aviso
end
# GET /consolidacoes/arquivadas — histórico de consolidações arquivadas
def arquivadas
authorize Consolidacao
@@ -248,7 +323,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 +381,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 +398,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 +413,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 +438,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 +466,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 +480,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 +524,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

@@ -2,12 +2,37 @@
class DashboardController < ApplicationController
MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre)
def index
skip_authorization
# Tipos lançados EM LOTE: uma linha só de consolidacao_entregas representa N
# entregas (quantidade = N, valor_aplicado = N × preço — ver
# Consolidacao#adicionar_termos!). Contá-los por tracking_id valeria 1.
TIPOS_EM_LOTE = %w[termo termo_especial].freeze
# Cor de cada tipo no donut "Composição do custo". Fica AQUI, junto de quem
# monta @fin_por_tipo, para label, valor e cor viajarem no MESMO hash: a view
# tinha uma lista fixa de 5 cores para os 7 tipos de
# ConsolidacaoEntrega::TIPO_CORES e o Chart.js reciclava o array — "Entrega de
# Termo" saía com a mesma laranja de "Entrega Normal", no mesmo gráfico.
# `desconto` NÃO entra: ver o comentário em #carregar_dados_financeiro.
CORES_TIPO = {
'entrega_normal' => '#f97316',
'retirada' => '#9a3412',
'bonus' => '#fed7aa',
'extraordinaria' => '#9333ea',
'termo' => '#2563eb',
'termo_especial' => '#06b6d4'
}.freeze
def index
# 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 +40,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 +77,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).
@@ -76,35 +138,50 @@ class DashboardController < ApplicationController
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
# PAGAS pela DATA REAL (checkout) — base de toda a análise financeira por data.
pagas = Entrega.da_conta_gade.pagas.no_periodo_checkout(@periodo_inicio, @periodo_fim)
pagas = pagas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
# ATENDIDAS pela DATA REAL (checkout) — base de toda a análise financeira.
#
# Atendida = o motorista FOI ao local e fechou a visita, com sucesso
# (`completed`) OU insucesso (`failed`). É exatamente o conjunto que a
# consolidação considera elegível e paga (ver Entrega::STATUS_ATENDIDO,
# Entrega.contar_atendidas e Consolidacao#veiculos_do_motorista), por isso o
# dashboard usa o MESMO recorte — usava só `pagas` (concluídas) e mostrava
# um valor menor que o do fechamento.
atendidas = Entrega.da_conta_gade.atendidas.no_periodo_checkout(@periodo_inicio, @periodo_fim)
atendidas = atendidas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
# PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada.
# PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada
# é o único recorte sem checkout, então planned_date é o eixo correto aqui.
pendentes = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim)
pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
# FALHADAS (insucesso) pela data planejada — também não têm checkout.
falhadas = Entrega.da_conta_gade.falhadas.no_periodo(@periodo_inicio, @periodo_fim)
falhadas = falhadas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
# Totais gerais
@entregas_pagas = pagas.count
# Quebra do total entre sucesso e insucesso (ambos já dentro de `atendidas`).
# ⚠️ Falhada TEM checkout: o motorista foi ao local e fechou a visita com
# motivo de insucesso. Por isso o eixo é checkout, igual às concluídas — antes
# as falhas eram filtradas por planned_date e caíam no período errado.
@entregas_pagas = atendidas.concluidas.count
@entregas_falhadas = atendidas.falhadas.count
@entregas_atendidas = @entregas_pagas + @entregas_falhadas
@entregas_pendentes = pendentes.count
@entregas_falhadas = falhadas.count
@total_entregas = @entregas_pagas + @entregas_pendentes + @entregas_falhadas
@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
# Valor estimado total
@valor_estimado = (@entregas_pagas * config[:entrega]).round(2)
@valor_estimado = (@entregas_atendidas * config[:entrega]).round(2)
# Por motorista (todos)
@motoristas = pagas.group(:driver)
.count
.sort_by { |_, v| -v }
.map do |driver, qtd|
@motoristas = atendidas.group(:driver)
.count
.sort_by { |_, v| -v }
.map do |driver, qtd|
{
nome: driver,
entregas: qtd,
@@ -113,14 +190,14 @@ class DashboardController < ApplicationController
end
# Por local de entrega (contact_name)
@por_operacao = pagas.group(:contact_name)
.count
.sort_by { |_, v| -v }
.first(6)
.to_h
@por_operacao = atendidas.group(:contact_name)
.count
.sort_by { |_, v| -v }
.first(6)
.to_h
# Evolução diária no período (para Chart.js) — por data real (checkout)
@grafico_diario = build_grafico_diario(pagas, config[:entrega])
@grafico_diario = build_grafico_diario(atendidas, config[:entrega])
# Consolidações cujo período (data_inicio..data_fim) cruza o período do dashboard
# — independe de quando foram criadas (created_at).
@@ -130,12 +207,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)
@@ -189,11 +267,26 @@ class DashboardController < ApplicationController
consols = Consolidacao.where(id: (ids_periodo + ids_pagos).uniq).to_a
ids = consols.map(&:id)
cms = ConsolidacaoMotorista.ativos.where(consolidacao_id: ids)
ces = ConsolidacaoEntrega.where(consolidacao_id: ids)
# ⚠️ O MESMO RECORTE DE `cms`, NO NÍVEL DA LINHA. Motorista arquivado sai do
# dinheiro (ConsolidacaoMotorista.ativos), mas as linhas dele CONTINUAM em
# consolidacao_entregas: `arquivar_motorista` é reversível e preserva tudo —
# quem apaga é o `excluir_motorista`. Sem este EXISTS, o card "Custo total"
# mostrava R$ de um conjunto e "N entregas classificadas" de outro, o ticket
# médio dividia um pelo outro e o donut de composição não fechava com o total.
#
# O par é (consolidacao_id, motorista_nome), não só o nome: o mesmo motorista
# pode estar ativo numa consolidação e arquivado em outra, ambas no período.
ces = ConsolidacaoEntrega.where(consolidacao_id: ids).where(<<~SQL.squish)
EXISTS (SELECT 1 FROM consolidacao_motoristas cm
WHERE cm.consolidacao_id = consolidacao_entregas.consolidacao_id
AND cm.motorista_nome = consolidacao_entregas.motorista_nome
AND cm.arquivado_em IS NULL)
SQL
# KPIs
@fin_custo_total = cms.sum(:valor_total)
@fin_qtd_entregas = ces.distinct.count(:tracking_id)
@fin_qtd_entregas = contar_entregas(ces)
@fin_ticket_medio = @fin_qtd_entregas.zero? ? 0 : (@fin_custo_total / @fin_qtd_entregas)
@fin_pago = cms.pagos.sum(:valor_total)
@fin_pendente = cms.pendentes.sum(:valor_total)
@@ -208,33 +301,83 @@ class DashboardController < ApplicationController
end
@fin_por_operacao = por_op.sort_by { |_, v| -v }
# Composição por tipo (normal/retirada/bônus/desconto)
# Composição por tipo (normal/retirada/bônus/)
#
# ⚠️ DESCONTO NÃO É FATIA. No dinheiro ele SUBTRAI (ConsolidacaoMotorista
# #recalcular_valor e Consolidacao#recalcular_motorista! usam
# `-valor_aplicado`). Como fatia positiva de um DONUT — que se lê como
# "partes de um todo" — a soma das fatias ficava maior que o Custo total
# mostrado logo acima, e a diferença era exatamente o desconto. Ele sai do
# gráfico e vira a linha "já abatidos" embaixo: a divergência é legítima,
# então é explicada no lugar onde ela aparece (diretriz 1).
soma_tipo = ces.group(:tipo).sum(:valor_aplicado)
@fin_por_tipo = ConsolidacaoEntrega::TIPO_CORES.filter_map do |tipo, cfg|
@fin_descontos = soma_tipo['desconto'].to_f
@fin_por_tipo = CORES_TIPO.filter_map do |tipo, cor|
valor = soma_tipo[tipo].to_f
next if valor.zero?
{ label: cfg[:label], valor: valor }
{ label: ConsolidacaoEntrega::TIPO_CORES.dig(tipo, :label) || tipo, valor: valor, cor: cor }
end
# Custo por motorista (todos)
@fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total)
.sort_by { |_, v| -v }
# Ranking CONSOLIDADO (aba "Consolidado" do card Motoristas): o valor REAL
# fechado por motorista + quantas entregas entraram nesse fechamento.
#
# ⚠️ A quantidade é DISTINCT tracking_id, NÃO a contagem de linhas: a mesma
# entrega pode ter vários pilares (Normal + Bônus + Retirada = 3 linhas em
# consolidacao_entregas), e contar linhas inflaria o número de entregas.
#
# Partimos de @fin_por_motorista (que vem de `cms`, já .ativos) e só buscamos
# a quantidade: assim a aba lista exatamente os motoristas do fechamento —
# arquivado não aparece — e a soma bate com o KPI "Custo total" do topo.
qtd_por_motorista = contar_entregas_por_motorista(ces)
@ranking_consolidado = @fin_por_motorista.map do |nome, valor|
{ nome: nome, valor: valor, entregas: qtd_por_motorista[nome].to_i }
end
end
# `pagas` já vem filtrado por checkout no período. Agrupa pela DATA REAL
# (DATE(checkout)) — DATE() sobre timestamp não faz conversão de fuso.
def build_grafico_diario(pagas, preco_entrega)
# Quantas ENTREGAS existem num conjunto de linhas de consolidacao_entregas.
# São DUAS correções na mesma conta, e as duas precisam conviver:
#
# • DISTINCT tracking_id — a MESMA entrega vira várias linhas quando tem mais
# de um pilar (Normal + Bônus + Retirada = 3 linhas). Contar linhas inflava
# o número de entregas. Esta parte já estava certa.
# • termo soma `quantidade` — um lote de termo é UMA linha com quantidade N
# (Consolidacao#adicionar_termos!), então por tracking_id um lote de 20
# valia 1: o custo entrava inteiro no numerador e 1 no denominador, e o
# ticket médio saía 20× maior. É a mesma unidade que
# Analytics::TotaisPorMotorista já usa ("um termo com quantidade 5 vale 5").
def contar_entregas(escopo)
escopo.where.not(tipo: TIPOS_EM_LOTE).distinct.count(:tracking_id) +
escopo.where(tipo: TIPOS_EM_LOTE).sum(:quantidade)
end
# Mesma conta de #contar_entregas, quebrada por motorista — o ranking e o KPI
# do topo têm que somar a mesma coisa, senão viram dois números na mesma tela.
def contar_entregas_por_motorista(escopo)
normais = escopo.where.not(tipo: TIPOS_EM_LOTE).group(:motorista_nome).distinct.count(:tracking_id)
lotes = escopo.where(tipo: TIPOS_EM_LOTE).group(:motorista_nome).sum(:quantidade)
normais.merge(lotes) { |_nome, a, b| a + b }
end
# `atendidas` já vem filtrado por checkout no período (concluídas + falhadas).
# Agrupa pela DATA REAL (DATE(checkout)) — DATE() sobre timestamp não faz
# conversão de fuso.
def build_grafico_diario(atendidas, preco_entrega)
dias = (@periodo_inicio..@periodo_fim).to_a
if dias.size <= MAX_DIAS_GRAFICO
# Granularidade diária (períodos curtos)
contagem = pagas.group("DATE(checkout)").count.transform_keys(&:to_s)
contagem = atendidas.group("DATE(checkout)").count.transform_keys(&:to_s)
labels = dias.map { |d| d.strftime('%d/%m') }
valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) }
qtds = dias.map { |d| contagem[d.to_s] || 0 }
else
# Período longo: agrega por mês para o gráfico não ficar vazio nem poluído.
contagem = pagas.group("TO_CHAR(checkout, 'YYYY-MM')").count
contagem = atendidas.group("TO_CHAR(checkout, 'YYYY-MM')").count
meses = meses_no_periodo
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
valores = meses.map { |m| ((contagem[m] || 0) * preco_entrega).round(2) }

View File

@@ -3,35 +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 pagas × preco_entrega
@estimado_inicio = Date.current.beginning_of_month
@estimado_fim = Date.current
entregas_mes = Entrega.pagas.da_conta_gade
.do_motorista(nome)
.no_periodo(@estimado_inicio, @estimado_fim)
.count
@valor_estimado = entregas_mes * Configuracao.preco_entrega
@entregas_mes = entregas_mes
@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,
@@ -42,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,221 @@
import { Controller } from "@hotwired/stimulus"
// Editor do romaneio: salva cada campo assim que ele muda e reatualiza a prévia
// (que é o PDF de verdade, num iframe).
//
// Por que salvar POR CAMPO e não com um submit de formulário: `campos_editados`
// precisa registrar exatamente o campo que um humano tocou. Um submit em bloco não
// distingue "redigitei o mesmo valor" de "nunca olhei" — marcaria os 5 campos das 29
// linhas como editados, e a reimportação seguinte preservaria endereços velhos no
// plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com
// escrita por campo.
export default class extends Controller {
// `overlay` é o <details> da prévia (nome mantido para não renomear em vão);
// `caixaPreview` saiu junto com o modal.
static targets = ["celula", "preview", "itemVeiculo", "motorista", "rotulo", "status", "linha", "busca",
"contador", "overlay", "tituloPreview"]
static values = { url: String, pdfUrl: String, veiculo: String }
// Recarregar o PDF a cada tecla travaria a tela; 600ms depois do último save é o
// suficiente para parecer imediato sem regerar o documento à toa.
static ATRASO_PREVIEW = 600
connect() {
this.timerPreview = null
// A prévia nasce fechada e DESATUALIZADA: o PDF só é gerado na primeira vez
// que alguém pede para ver. Antes o iframe era montado a cada carregamento da
// tela e gerava um PDF que muitas vezes ninguém olhava.
this.previewDesatualizado = true
// Com 72 carros a lista rola; sem isto, ao trocar de veículo o selecionado
// pode nascer fora da área visível e parece que a seleção não pegou.
const atual = this.itemVeiculoTargets.find((a) => a.dataset.atual === "1")
if (atual) atual.scrollIntoView({ block: "nearest", inline: "nearest" })
}
disconnect() {
if (this.timerPreview) clearTimeout(this.timerPreview)
}
// ── Edição ──────────────────────────────────────────────────────────────
salvarCelula(event) {
const input = event.currentTarget
// Sair do campo sem mudar nada não é edição — marcar como editado aqui
// congelaria o valor contra a próxima reimportação sem o operador querer.
if (input.value === input.dataset.original) return
this.gravar(input.dataset.linhaId, input.dataset.campo, input.value, input)
}
restaurar(event) {
const botao = event.currentTarget
const { linhaId, campo } = botao.dataset
const input = this.celulaTargets.find(
(c) => c.dataset.linhaId === linhaId && c.dataset.campo === campo
)
// valor null no corpo = "devolve o que o plano trouxe e destrava o campo"
this.gravar(linhaId, campo, null, input, botao)
}
alternarAparelho(event) {
const botao = event.currentTarget
const novo = botao.dataset.valor === "SIM" ? "" : "SIM"
this.gravar(botao.dataset.linhaId, "aparelho", novo, null, null, (dados) => {
botao.dataset.valor = dados.valor
botao.textContent = dados.valor || "—"
const estilo = dados.valor === "SIM"
? "bg-green-600/20 text-green-300 border-green-600/40"
: "bg-[#1a1a1a] text-gray-500 border-white/10"
botao.className = `${estilo} w-full min-h-[40px] rounded-lg border font-semibold text-xs hover:border-orange-500 transition-colors`
this.marcar(botao.dataset.linhaId, "aparelho", dados.editado)
})
}
salvarRotulo() {
this.enviar(`${this.urlValue}/atualizar_rotulo`, { rotulo_plano: this.rotuloTarget.value })
.then(() => this.agendarPreview())
}
salvarMotorista() {
// Não mexe na prévia: o nome do motorista não sai impresso — a linha do
// rodapé é preenchida à mão na retirada.
this.enviar(`${this.urlValue}/atualizar_veiculo`, {
veiculo: this.veiculoValue,
motorista: this.motoristaTarget.value
})
}
// ── Navegação e filtro ──────────────────────────────────────────────────
// A lista de veículos são LINKS de verdade (o veículo vive na query string), então
// clicar já navega sem JS. As setas só precisam achar o vizinho na mesma lista —
// é o que o operador faz o dia inteiro: percorrer carro a carro.
anterior() { this.mover(-1) }
proximo() { this.mover(1) }
mover(passo) {
const itens = this.itemVeiculoTargets
if (itens.length === 0) return
const atual = itens.findIndex((a) => a.dataset.atual === "1")
const destino = (atual === -1 ? 0 : atual) + passo
// Sem dar a volta de propósito: chegar na ponta e voltar ao primeiro carro faz
// o operador reimprimir folha que já separou, sem perceber.
if (destino < 0 || destino >= itens.length) return
window.location.href = itens[destino].href
}
filtrar() {
const termo = this.hasBuscaTarget ? this.buscaTarget.value.trim().toLowerCase() : ""
let visiveis = 0
this.linhaTargets.forEach((linha) => {
const casa = termo === "" || linha.dataset.busca.includes(termo)
linha.hidden = !casa
if (casa) visiveis += 1
})
// Sem o contador, uma busca que não casa com nada deixa a tabela vazia e
// parece plano vazio — o operador acha que perdeu o romaneio.
if (!this.hasContadorTarget) return
const total = this.linhaTargets.length
const plural = visiveis === 1 ? "parada" : "paradas"
this.contadorTarget.textContent = termo === ""
? `${total} ${total === 1 ? "parada" : "paradas"} nesta folha`
: `${visiveis} de ${total} ${plural} — filtrando por "${termo}"`
}
limparBusca() {
if (!this.hasBuscaTarget) return
this.buscaTarget.value = ""
this.filtrar()
this.buscaTarget.focus()
}
// ── Prévia (pop-up; estado na URL, ?previa=1) ───────────────────────────
// Abrir, fechar e trocar de folha são LINKS comuns — nada disso depende deste
// controller, e é o que mantém a prévia viva quando o JS não carrega. O
// overlay só existe no DOM quando a prévia está aberta, então a presença do
// alvo já é a resposta. O que sobra aqui é recarregar a folha após uma edição.
previewAberta() {
return this.hasOverlayTarget
}
carregarPreview() {
if (!this.hasPreviewTarget) return
// Cache-bust em vez de reload(): reload reinicializa o visualizador de PDF do
// navegador e a tela pisca a cada salvamento.
this.previewTarget.src =
`${this.pdfUrlValue}?veiculo=${encodeURIComponent(this.veiculoValue)}&t=${Date.now()}`
this.previewDesatualizado = false
}
// Chamada depois de cada gravação. Com o pop-up FECHADO não gera PDF nenhum —
// o `loading="lazy"` do iframe já garante isso, e recarregar o src de um
// iframe escondido forçaria o servidor a montar um PDF que ninguém vai ver.
atualizarPreview() {
if (!this.previewAberta()) {
this.previewDesatualizado = true
return
}
this.carregarPreview()
}
// ── Infra ───────────────────────────────────────────────────────────────
gravar(linhaId, campo, valor, input, botao, aoConcluir) {
this.status("Salvando…")
this.enviar(`${this.urlValue}/atualizar_linha`, { linha_id: linhaId, campo, valor })
.then((dados) => {
if (!dados || !dados.ok) return
if (input) {
// Escrevemos de volta o valor QUE O SERVIDOR GRAVOU: a escrita é
// last-write-wins, e assim uma sobreposição de duas abas fica visível em
// vez de silenciosa.
input.value = dados.valor
input.dataset.original = dados.valor
}
this.marcar(linhaId, campo, dados.editado)
if (aoConcluir) aoConcluir(dados)
this.status("Salvo")
this.agendarPreview()
})
.catch(() => this.status("Não deu para salvar — tente de novo"))
}
marcar(linhaId, campo, editado) {
const alvo = this.element.querySelector(`[data-romaneio-marcador="${linhaId}-${campo}"]`)
if (!alvo) return
if (!editado) {
alvo.innerHTML = ""
return
}
alvo.innerHTML =
`<button type="button" title="Editado — não será sobrescrito na reimportação. Clique para restaurar o valor do plano." ` +
`data-action="romaneio#restaurar" data-linha-id="${linhaId}" data-campo="${campo}" ` +
`class="text-orange-400 hover:text-orange-200 text-xs leading-none">●</button>`
}
agendarPreview() {
if (this.timerPreview) clearTimeout(this.timerPreview)
this.timerPreview = setTimeout(() => this.atualizarPreview(), this.constructor.ATRASO_PREVIEW)
}
enviar(url, corpo) {
return fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content
},
body: JSON.stringify(corpo)
}).then((r) => (r.ok ? r.json() : Promise.reject(r)))
}
status(texto) {
if (this.hasStatusTarget) this.statusTarget.textContent = texto
}
}

View File

@@ -0,0 +1,50 @@
import { Controller } from "@hotwired/stimulus"
// Seleção em massa da lista de consolidações (?visao=lista): marca várias e
// arquiva de uma vez.
//
// POR QUE ELE É TÃO PEQUENO: o formulário é HTML de verdade — os checkboxes
// carregam `name="ids[]"` e `form="form-arquivar-lote"`, e o botão é um submit
// comum. Este controller só cuida do CONFORTO (marcar todas, contar, desabilitar
// quando não há nada marcado). Se o JS não carregar — e já carregou 404 em
// produção, ver README de 28/08/2026 — marcar na mão e enviar continua
// funcionando, e o botão continua habilitado porque quem desabilita é ele.
//
// "Selecionar todas" marca TODAS as da lista filtrada, inclusive as que o
// carrossel escondeu na página seguinte: o carrossel só põe a classe `hidden`,
// os campos continuam no DOM e são enviados. É o que se espera de "em lote" —
// e o contador diz o número, então não há surpresa no envio.
export default class extends Controller {
static targets = ["todos", "item", "contador", "botao"]
connect() {
this.atualizar()
}
alternarTodos() {
this.itemTargets.forEach((c) => { c.checked = this.todosTarget.checked })
this.atualizar()
}
atualizar() {
const marcados = this.itemTargets.filter((c) => c.checked).length
const total = this.itemTargets.length
if (this.hasTodosTarget) {
this.todosTarget.checked = total > 0 && marcados === total
// Estado "algumas": o quadradinho vira traço em vez de mentir que está
// tudo (ou nada) marcado.
this.todosTarget.indeterminate = marcados > 0 && marcados < total
}
if (this.hasContadorTarget) {
this.contadorTarget.textContent =
marcados === 0 ? "Marque as que quer arquivar"
: `${marcados} de ${total} selecionada${marcados > 1 ? "s" : ""}`
this.contadorTarget.classList.toggle("text-gray-500", marcados === 0)
this.contadorTarget.classList.toggle("text-white", marcados > 0)
}
if (this.hasBotaoTarget) this.botaoTarget.disabled = marcados === 0
}
}

View File

@@ -7,6 +7,7 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [
"linha", "checkbox", "selecionarTodos", "busca", "buscaVazia",
"grupoPilares", "chipSelecao", "contadorSelecao", "btnLimpar",
"resumo", "listaCol", "btnResumoLabel",
"toggles", "barra", "barraManuais", "percentual", "chipVeiculo",
"consolidarBtn", "consolidarHint",
@@ -98,6 +99,32 @@ export default class extends Controller {
if (linha && linha.classList.contains("hidden")) return
c.checked = marcar
})
this.atualizarSelecao()
}
// ── Estado da seleção ──
// Os pilares ficam SEMPRE na tela; sem seleção apenas esmaecem. Esconder e
// remostrar mexia na altura do cabeçalho a cada clique e escondia a ação
// principal justamente de quem ainda não sabe que precisa selecionar antes.
atualizarSelecao() {
const n = this.checkboxTargets.filter(c => c.checked).length
if (this.hasContadorSelecaoTarget) this.contadorSelecaoTarget.textContent = n
if (this.hasChipSelecaoTarget) this.chipSelecaoTarget.classList.toggle("hidden", n === 0)
if (this.hasBtnLimparTarget) this.btnLimparTarget.classList.toggle("hidden", n === 0)
if (this.hasGrupoPilaresTarget) {
this.grupoPilaresTarget.classList.toggle("opacity-40", n === 0)
this.grupoPilaresTarget.title = n === 0
? "Marque ao menos uma entrega para aplicar em massa"
: `Aplicar em ${n} entrega(s) — clique de novo no pilar para remover`
}
}
limparSelecao() {
this.checkboxTargets.forEach(c => (c.checked = false))
if (this.hasSelecionarTodosTarget) this.selecionarTodosTarget.checked = false
this.atualizarSelecao()
}
// O pilar está ativo (ring-2) na entrega indicada?
@@ -137,8 +164,7 @@ export default class extends Controller {
valor: valor
})
ids.forEach(id => this.setToggle(id, tipo, !remover))
this.checkboxTargets.forEach(c => c.checked = false)
if (this.hasSelecionarTodosTarget) this.selecionarTodosTarget.checked = false
this.limparSelecao() // some a barra de ações junto com a seleção
this.atualizarResumo(resp)
}

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,149 @@
# app/jobs/sincronizar_series_aparelho_job.rb
#
# Busca na API do SimpliRoute o "Número de Série da Base (Aparelho)" das visitas
# e grava em series_aparelho, para a planilha Entregas do cliente sair com essa
# coluna preenchida.
#
# POR QUE EM BACKGROUND: o dado só existe na API (o espelho tem a coluna e nunca
# a preenche — 0 de ~6.000 linhas em fev/mar/mai/jul/2026). A API não filtra por
# intervalo: é uma chamada POR DATA, cada uma com o dia inteiro (~4 MB / ~9 s).
# Um mês são ~25 chamadas — inviável dentro do request do download, tranquilo
# num job noturno.
#
# Roda via Whenever → `rake simpli_route:series` (últimos DIAS_PADRAO dias).
# Para meses antigos, o backfill é manual: `rake simpli_route:series_operacao[tabela]`.
class SincronizarSeriesAparelhoJob < ApplicationJob
queue_as :default
# Mesmo limite da varredura do Admin::EdicaoLancamentosController — a API do
# SimpliRoute é lenta por dia, mas aguenta alguns dias em paralelo.
MAX_PARALELO = 6
# Janela padrão da rodada agendada. Cobre o mês corrente inteiro com folga,
# que é o que o cliente pede em planilha no dia a dia.
DIAS_PADRAO = 45
# Nomes conhecidos do campo em `extra_field_values`. O 1º que vier preenchido
# ganha; se nenhum casar, cai no fallback por /seri/ (ver #serie_de) — foi
# chutar nome de campo que deixou meia planilha vazia antes.
CAMPOS_SERIE = %w[
numero_de_serie_da_base_aparelho numero_de_serie_da_base numero_serie_base
num_serie_base serie_base numero_de_serie serie
].freeze
# Devolve um resumo { datas:, visitas:, series:, falhas: } para o rake logar.
def perform(de: nil, ate: nil, operacao: nil, dias: DIAS_PADRAO)
return resumo_vazio('SIMPLIROUTE_TOKEN não configurado') unless SimpliRoute.configurado?
datas = datas_alvo(de: de, ate: ate, operacao: operacao, dias: dias)
return resumo_vazio('nenhuma data com visitas no período') if datas.empty?
visitas = varrer(datas)
gravadas = gravar(visitas)
avisar_se_nada_casou(gravadas, visitas)
{ datas: datas.size, visitas: visitas.size, series: gravadas, falhas: @falhas }
end
private
# Datas que valem uma chamada à API. Sempre saem do espelho (planned_date das
# visitas que já conhecemos) — varrer dia sem visita é chamada jogada fora.
def datas_alvo(de:, ate:, operacao:, dias:)
escopo = Entrega.where.not(planned_date: nil)
escopo = escopo.da_operacoes([operacao]) if operacao.present?
inicio, fim = if de.present? && ate.present?
[de.to_date, ate.to_date]
elsif operacao.present?
[nil, nil] # operação inteira, sem recorte de data
else
[Date.current - dias.to_i, Date.current]
end
escopo = escopo.no_periodo(inicio, fim) if inicio && fim
escopo.distinct.pluck(Arel.sql('planned_date::date')).compact.sort
end
# Um dia que a API não responde não pode derrubar a rodada: registra e segue
# (mesma postura da varredura de NF do Admin::EdicaoLancamentosController).
def varrer(datas)
@falhas = []
mutex = Mutex.new
datas.each_slice(MAX_PARALELO).flat_map do |lote|
lote.map { |data| Thread.new { visitas_do_dia(data, mutex) } }.flat_map(&:value)
end
end
def visitas_do_dia(data, mutex)
client.visitas_da_data(data)
rescue SimpliRoute::Error => e
mutex.synchronize { @falhas << { data: data.to_s, erro: e.message } }
[]
end
# Grava só as visitas que TÊM série. upsert_all em vez de N inserts: uma
# operação de um mês passa de 1.000 visitas.
def gravar(visitas)
agora = Time.current
registros = visitas.filter_map do |visita|
serie = serie_de(visita)
next if serie.blank? || visita['tracking_id'].blank?
{
tracking_id: visita['tracking_id'],
reference_id: visita['reference'].to_s.presence,
numero_serie: serie,
planned_date: visita['planned_date'],
sincronizado_em: agora
}
end
# A mesma visita pode vir em duas datas varridas; upsert_all rejeita chave
# repetida no MESMO lote, então deduplica antes (fica a última).
registros = registros.index_by { |r| r[:tracking_id] }.values
return 0 if registros.empty?
SerieAparelho.upsert_all(registros, unique_by: :tracking_id, record_timestamps: true)
registros.size
end
# A série fica em `extra_field_values`, o mesmo hash das fotos do motorista.
# Ignora valor que seja URL: ali dentro convivem os campos foto_*.
def serie_de(visita)
extras = visita['extra_field_values']
return nil unless extras.is_a?(Hash)
chave = CAMPOS_SERIE.find { |c| texto_util?(extras[c]) } ||
extras.keys.find { |k| k.to_s.match?(/seri/i) && texto_util?(extras[k]) }
chave && extras[chave].to_s.strip
end
def texto_util?(valor)
v = valor.to_s.strip
v.present? && !v.match?(%r{\Ahttps?://}i)
end
# Varreu visitas e não achou UMA série? Quase certo que o campo mudou de nome
# na API. Loga as chaves que vieram para não virar falha silenciosa de novo.
def avisar_se_nada_casou(gravadas, visitas)
return if gravadas.positive? || visitas.empty?
chaves = visitas.filter_map { |v| v['extra_field_values'] }
.select { |e| e.is_a?(Hash) }.flat_map(&:keys).uniq.sort
Rails.logger.warn(
"[series_aparelho] #{visitas.size} visitas varridas e nenhuma série encontrada. " \
"Campos vistos em extra_field_values: #{chaves.inspect}. " \
'Se o campo da série estiver nessa lista, acrescente o nome em CAMPOS_SERIE.'
)
end
def client
@client ||= SimpliRoute::Client.new
end
def resumo_vazio(motivo)
Rails.logger.info("[series_aparelho] nada a fazer — #{motivo}.")
{ datas: 0, visitas: 0, series: 0, falhas: [], motivo: motivo }
end
end

View File

@@ -1,5 +1,22 @@
# app/mailers/application_mailer.rb
#
# O remetente e as credenciais SMTP saem do BANCO (ConfiguracaoNotificacao), em
# tempo de ENVIO — mudar na tela /admin/configuracao_notificacao vale sem
# reiniciar o container.
#
# ⚠️ Tem que ser `proc`, NÃO lambda. O Devise avalia o `default from:` com
# `instance_eval(&proc)`, que passa 1 argumento; um lambda de aridade 0
# estouraria ArgumentError em todo "esqueci minha senha".
#
# ⚠️ E tem que ser `delivery_method_options`, NÃO um before_action mexendo em
# `message.delivery_method`: o `mail()` roda DEPOIS dos callbacks e chama
# `wrap_delivery_behavior!`, que sobrescreveria o callback. O
# `delivery_method_options` é lido dentro do próprio `mail()` e MERGEADO por
# cima do smtp_settings da classe — é daí que vem, de graça, o fallback para
# o que o config/initializers/smtp.rb montou a partir do .env.
class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br')
layout 'mailer'
default from: proc { ConfiguracaoNotificacao.remetente },
delivery_method_options: proc { ConfiguracaoNotificacao.opcoes_de_entrega }
end

View File

@@ -1,6 +1,7 @@
# app/mailers/consolidacao_mailer.rb
class ConsolidacaoMailer < ApplicationMailer
default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br')
# Sem `default from:` aqui de propósito: redeclarar sobrescreveria o proc do
# ApplicationMailer e congelaria o remetente no valor que o .env tinha no boot.
def pagamento_fechado(user, consolidacao, consolidacao_motorista)
@user = user

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

@@ -0,0 +1,14 @@
# app/mailers/teste_mailer.rb
#
# E-mail disparado pelo botão "Salvar e enviar e-mail de teste" da tela
# /admin/configuracao_notificacao. Serve só para provar que as credenciais SMTP
# gravadas funcionam de verdade.
class TesteMailer < ApplicationMailer
def teste(destinatario, config, usuario = nil)
@config = config
@usuario = usuario
@quando = Time.current
mail to: destinatario, subject: '[Reem Notas] E-mail de teste'
end
end

View File

@@ -0,0 +1,71 @@
# app/models/concerns/atributo_cifrado.rb
#
# Cifra atributos sensíveis (senha SMTP, token do Twilio) em colunas
# `<nome>_cifrado` usando ActiveSupport::MessageEncryptor (AES-256-GCM).
#
# POR QUE NÃO ActiveRecord Encryption (`encrypts :campo`): este projeto não usa
# `config/credentials.yml.enc` nem `config/master.key` — tudo vem do .env. Ligar
# o AR Encryption exigiria configurar 3 chaves novas e depender da ordem de
# execução dos initializers do framework. Aqui a chave é DERIVADA do
# secret_key_base, sem nada novo para o operador gerar.
#
# ⚠️ ROTAÇÃO: se o SECRET_KEY_BASE do servidor for trocado, os valores já
# gravados viram ilegíveis. O reader devolve nil (nunca estoura 500), o app
# cai no fallback do .env e a tela mostra um aviso pedindo para redigitar.
# Para ficar imune a isso, defina NOTIFICACAO_SECRET no .env (qualquer string
# longa e fixa) — a derivação passa a usar ela em vez do secret_key_base.
module AtributoCifrado
extend ActiveSupport::Concern
SALT = 'reem-notas/notificacao/v1'
class_methods do
def atributo_cifrado(*nomes)
nomes.each do |nome|
coluna = :"#{nome}_cifrado"
define_method(nome) { AtributoCifrado.decifrar(self[coluna]) }
define_method(:"#{nome}=") do |valor|
texto = valor.to_s
# Sem isso, salvar a tela sem mexer no campo geraria um criptograma
# novo (o IV é aleatório) e o dirty tracking acusaria mudança à toa.
next if texto == send(nome)
self[coluna] = texto.blank? ? nil : AtributoCifrado.cifrar(texto)
end
define_method(:"#{nome}?") { send(nome).present? }
end
end
end
def self.cifrar(texto)
cofre.encrypt_and_sign(texto)
end
def self.decifrar(blob)
return nil if blob.blank?
cofre.decrypt_and_verify(blob)
rescue ActiveSupport::MessageEncryptor::InvalidMessage,
ActiveSupport::MessageVerifier::InvalidSignature => e
Rails.logger.error("[AtributoCifrado] valor ilegível (SECRET_KEY_BASE mudou?): #{e.class}")
nil
end
# O default do MessageEncryptor no Rails 7.1 é aes-256-gcm → chave de 32 bytes.
def self.cofre
@cofre ||= ActiveSupport::MessageEncryptor.new(chave)
end
def self.chave
if (segredo = ENV['NOTIFICACAO_SECRET'].presence)
ActiveSupport::KeyGenerator
.new(segredo, hash_digest_class: OpenSSL::Digest::SHA256)
.generate_key(SALT, 32)
else
Rails.application.key_generator.generate_key(SALT, 32)
end
end
end

View File

@@ -11,10 +11,18 @@ class Configuracao < ApplicationRecord
preco_extraordinaria
preco_termo
preco_termo_especial
# ⚠️ OBSOLETAS: quem liga/desliga notificação agora é ConfiguracaoNotificacao
# (tela /admin/configuracao_notificacao). Nenhum código lê estas duas chaves
# desde a migration CreateConfiguracaoNotificacoes, que copiou os valores.
# Continuam aqui só para não quebrar as linhas já existentes no banco.
notificacao_whatsapp
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

@@ -0,0 +1,248 @@
# app/models/configuracao_notificacao.rb
#
# Configuração de envio (SMTP + WhatsApp/Twilio) editável pelo ADM na tela
# /admin/configuracao_notificacao. Tabela SINGLETON: sempre uma linha só.
#
# HIERARQUIA: banco > .env. Enquanto a tela não estiver preenchida e marcada
# como ativa, `smtp_settings` devolve nil e `credenciais_whatsapp` cai no ENV —
# ou seja, o comportamento é idêntico ao que existia antes desta tela.
class ConfiguracaoNotificacao < ApplicationRecord
include AtributoCifrado
self.table_name = 'configuracao_notificacoes'
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'
before_validation :normalizar
validates :smtp_address, presence: true, if: :smtp_ativo?
validates :smtp_username, presence: true, if: :smtp_ativo?
validates :smtp_port, numericality: { only_integer: true,
greater_than: 0, less_than_or_equal_to: 65_535 }
validates :smtp_autenticacao, inclusion: { in: AUTENTICACOES }
validates :remetente_email, format: { with: EMAIL_RE }, allow_blank: true
validates :email_admin, format: { with: EMAIL_RE }, allow_blank: true
validate :senha_smtp_presente, if: :smtp_ativo?
validates :whatsapp_provedor, inclusion: { in: PROVEDORES_WHATSAPP }
validates :whatsapp_intervalo_segundos,
numericality: { only_integer: true, greater_than_or_equal_to: 0,
less_than_or_equal_to: 300 }
# As credenciais do Twilio só são exigidas quando ele É o provedor escolhido.
validates :twilio_account_sid, presence: true, if: :twilio_exigido?
validates :twilio_from, presence: true, if: :twilio_exigido?
validate :token_twilio_presente, if: :twilio_exigido?
validate :baileys_configuravel, if: :baileys_exigido?
# ── Singleton ───────────────────────────────────────────────
def self.instancia
first || create!
rescue ActiveRecord::RecordNotUnique
# Dois workers Puma criando a primeira linha ao mesmo tempo.
first
rescue ActiveRecord::StatementInvalid,
ActiveRecord::NoDatabaseError,
ActiveRecord::ConnectionNotEstablished => e
# Boot do container antes do db:migrate, assets:precompile, console sem banco…
Rails.logger.warn("[ConfiguracaoNotificacao] tabela indisponível (#{e.class}) — usando .env")
new
end
# ── Usados pelo ApplicationMailer: NUNCA podem estourar ─────
# (um erro aqui derrubaria todo envio de e-mail, inclusive o reset de senha)
def self.remetente
instancia.remetente_formatado
rescue StandardError => e
Rails.logger.error("[ConfiguracaoNotificacao.remetente] #{e.class}: #{e.message}")
ENV.fetch('SMTP_USERNAME', REMETENTE_PADRAO)
end
# nil => o ActionMailer faz `.merge({})` e mantém o que o
# config/initializers/smtp.rb montou a partir do .env no boot.
def self.opcoes_de_entrega
instancia.smtp_settings
rescue StandardError => e
Rails.logger.error("[ConfiguracaoNotificacao.opcoes_de_entrega] #{e.class}: #{e.message}")
nil
end
# ── SMTP ────────────────────────────────────────────────────
def smtp_pronto?
smtp_ativo? && smtp_address.present? && smtp_port.to_i.positive? &&
smtp_username.present? && smtp_password.present?
end
def smtp_settings
return nil unless smtp_pronto?
porta = smtp_port.to_i
base = {
address: smtp_address,
port: porta,
user_name: smtp_username,
password: smtp_password,
domain: smtp_domain.presence || dominio_padrao,
authentication: (smtp_autenticacao.presence || 'plain').to_sym,
# Sem timeout, uma porta filtrada pelo firewall do VPS pendura o worker
# Puma até o Nginx cortar — o botão "enviar teste" ficaria girando.
open_timeout: 10,
read_timeout: 10
}
# 465 = SSL implícito; 587/25 = STARTTLS.
base.merge(porta == 465 ? { ssl: true, tls: true } : { enable_starttls_auto: true })
end
def remetente_formatado
endereco = remetente_email.presence ||
smtp_username.presence ||
ENV.fetch('SMTP_USERNAME', REMETENTE_PADRAO)
remetente_nome.present? ? %("#{remetente_nome}" <#{endereco}>) : endereco
end
def email_habilitado?
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? &&
twilio_auth_token.present? && twilio_from.present?
end
def whatsapp_habilitado?
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
# seria outra e o Twilio devolveria 20003 sem explicação óbvia).
def credenciais_whatsapp
if whatsapp_pronto?
{ sid: twilio_account_sid,
token: twilio_auth_token,
from: self.class.canal(twilio_from) }
elsif ENV['TWILIO_ACCOUNT_SID'].present? && ENV['TWILIO_AUTH_TOKEN'].present?
{ sid: ENV['TWILIO_ACCOUNT_SID'],
token: ENV['TWILIO_AUTH_TOKEN'],
from: self.class.canal(ENV['TWILIO_WHATSAPP_FROM']) }
end
end
# ── Telefone ────────────────────────────────────────────────
# "11 920051157", "(11) 92005-1157", "011 92005-1157", "+55 11 92005-1157"
# e "whatsapp:+5511920051157" → "+5511920051157".
def self.normalizar_telefone(valor)
return nil if valor.blank?
d = valor.to_s.gsub(/\D/, '').sub(/\A0+/, '')
# Só tira o 55 quando sobra número demais — senão quebraria o DDD 55
# (Santa Maria/RS), onde "55991234567" já é o número completo.
d = d.sub(/\A55/, '') if d.length > 11 && d.start_with?('55')
return nil unless d.length.between?(10, 11)
"+55#{d}"
end
def self.canal(numero)
e164 = normalizar_telefone(numero)
return nil if e164.blank?
"whatsapp:#{e164}"
end
private
def dominio_padrao
(remetente_email.presence || smtp_username).to_s.split('@').last.presence || 'localhost'
end
def normalizar
self.smtp_address = smtp_address.to_s.strip.presence
self.smtp_username = smtp_username.to_s.strip.presence
self.smtp_domain = smtp_domain.to_s.strip.presence
self.remetente_email = remetente_email.to_s.strip.downcase.presence
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?
errors.add(:smtp_password,
if smtp_password_cifrado.present?
'gravada está ilegível (o SECRET_KEY_BASE do servidor mudou?). Digite a senha novamente.'
else
'é obrigatória quando o envio de e-mail está ativo'
end)
end
def token_twilio_presente
return if twilio_auth_token.present?
errors.add(:twilio_auth_token,
if twilio_auth_token_cifrado.present?
'gravado está ilegível (o SECRET_KEY_BASE do servidor mudou?). Cole o token novamente.'
else
'é obrigatório quando o WhatsApp está ativo'
end)
end
end

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
@@ -121,12 +141,20 @@ class Entrega < ApplicationRecord
# Veículos usados pelo(s) motorista(s) selecionado(s) no período — alimenta o
# filtro dinâmico de veículos na criação de consolidação.
#
# ⚠️ Precisa usar EXATAMENTE o mesmo recorte da elegibilidade
# (`atendidas` + `no_periodo_checkout`, ver contar_atendidas e
# Consolidacao#veiculos_do_motorista). Usava `no_periodo` (planned_date), e o
# descasamento sumia com veículo do filtro: entrega planejada em 31/07 e
# entregue em 01/08 é elegível numa consolidação 01/0814/08 (checkout dentro),
# mas o veículo dela não aparecia (planned_date fora) — sem opção de marcar,
# a entrega ficava de fora do fechamento SEM aviso, e o financeiro não batia.
def self.veiculos_de_motoristas(motoristas, inicio: nil, fim: nil)
nomes = Array(motoristas).reject(&:blank?)
return [] if nomes.empty?
base = da_conta_gade.where(driver: nomes)
base = base.no_periodo(inicio, fim) if inicio.present? && fim.present?
base = base.atendidas.no_periodo_checkout(inicio, fim) if inicio.present? && fim.present?
base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?)
end

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

@@ -0,0 +1,23 @@
# app/models/serie_aparelho.rb
#
# Número de série da base (aparelho) de UMA visita do SimpliRoute, buscado na
# API pelo SincronizarSeriesAparelhoJob.
#
# Esta é uma tabela NOSSA (gravável) — existe porque o espelho read-only
# db_reem_simplerout_2026 tem a coluna `numero_de_serie_da_base_aparelho` mas
# nunca a preenche. Ver a migration para o histórico.
class SerieAparelho < ApplicationRecord
self.table_name = 'series_aparelho'
validates :tracking_id, presence: true, uniqueness: true
validates :numero_serie, presence: true
# { tracking_id => numero_serie } das visitas informadas — uma consulta só,
# para a planilha não fazer N+1 ao preencher a coluna.
def self.por_tracking(tracking_ids)
ids = Array(tracking_ids).compact.uniq
return {} if ids.empty?
where(tracking_id: ids).pluck(:tracking_id, :numero_serie).to_h
end
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

@@ -0,0 +1,7 @@
# app/policies/configuracao_notificacao_policy.rb
#
# Credenciais de SMTP/Twilio e limites de envio.
class ConfiguracaoNotificacaoPolicy < ApplicationPolicy
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,11 +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
def show? = admin?
def buscar? = 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

@@ -42,17 +42,35 @@ module Analytics
'nota_fiscal' => 'NOTA FISCAL'
}.freeze
# Colunas da gade cujo nome real difere da chave de COLUNAS_GADE (a chave é
# o alias usado no SELECT e na leitura da linha). As tabelas do cliente
# gravam a geo como `lat`/`long`, não `latitude`/`longitude` — sem isso as
# duas colunas saíam vazias. Ordem = preferência; a 1ª que existir é usada.
ALIAS_GADE = {
'latitude' => %w[latitude lat],
'longitude' => %w[longitude long]
}.freeze
# Cabeçalho completo A..Z da aba ENTREGAS (gade + resultado do rastreio).
CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze
# Aba SimpliRoute: cabeçalho exato do modelo -> colunas candidatas na tabela
# de rastreio (a primeira que existir é usada; nenhuma existente = vazia).
#
# O 1º candidato de cada linha é o nome REAL conferido na
# db_reem_simplerout_2026 (o espelho usa os nomes do export do SimpliRoute:
# `codrivers`, `responsible_person`, `advance`, `start_of_time_window_1`,
# `required_skills`...). Os demais candidatos são só rede de segurança para
# o dia em que o espelho for recriado com outra grafia.
#
# 'Load 4' fica sem candidato de propósito: essa coluna NÃO existe no
# espelho (só load, load_2 e load_3) — sai vazia, preservando o layout.
RASTREIO_COLUNAS = {
'Tracking ID' => %w[tracking_id],
'Referência ID' => %w[reference_id],
'Data prevista' => %w[planned_date],
'Motorista' => %w[driver],
'Co-pilotos' => %w[copilots co_pilotos copilotos],
'Co-pilotos' => %w[codrivers copilots co_pilotos copilotos],
'Veículo' => %w[vehicle],
'Título' => %w[title titulo],
'Endereço' => %w[address],
@@ -60,10 +78,10 @@ module Analytics
'ETD' => %w[etd],
'Checkin' => %w[checkin],
'Checkout' => %w[checkout],
'Responsável' => %w[receiver responsavel responsible],
'Responsável' => %w[responsible_person receiver responsavel responsible],
'Tempo de serviço estimado' => %w[estimated_service_time tempo_de_servico_estimado duration],
'Tempo de serviço realme' => %w[real_service_time tempo_de_servico_real],
'Antecipação' => %w[early antecipacao anticipation],
'Antecipação' => %w[advance early antecipacao anticipation],
'Atraso' => %w[delay atraso],
'Latitude' => %w[latitude],
'Longitude' => %w[longitude],
@@ -72,16 +90,16 @@ module Analytics
'Load' => %w[load load_1 carga],
'Load 2' => %w[load_2],
'Load 3' => %w[load_3],
'Load 4' => %w[load_4],
'Load 4' => %w[],
'Estado' => %w[status],
'Comentários' => %w[checkout_comment comments comentarios comment],
'Comentários' => %w[comments checkout_comment comentarios comment],
'Observações' => %w[observation observacoes],
'Janela de horário Inicial 1' => %w[window_start window_start_1 janela_inicial_1],
'Janela de horário Final 1' => %w[window_end window_end_1 janela_final_1],
'Janela de horário Inicial 2' => %w[window_start_2 janela_inicial_2],
'Janela de horário Final 2' => %w[window_end_2 janela_final_2],
'Habilidades necessárias' => %w[skills_required habilidades_necessarias],
'Habilidades adicionais' => %w[skills_optional habilidades_adicionais],
'Janela de horário Inicial 1' => %w[start_of_time_window_1 window_start janela_inicial_1],
'Janela de horário Final 1' => %w[end_of_time_window_1 window_end janela_final_1],
'Janela de horário Inicial 2' => %w[start_of_time_window_2 window_start_2 janela_inicial_2],
'Janela de horário Final 2' => %w[end_of_time_window_2 window_end_2 janela_final_2],
'Habilidades necessárias' => %w[required_skills skills_required habilidades_necessarias],
'Habilidades adicionais' => %w[optional_skills skills_optional habilidades_adicionais],
'Notas' => %w[notes notas],
'Nome de contato' => %w[contact_name],
'Telefone de contato' => %w[contact_phone telefone_de_contato],
@@ -93,7 +111,10 @@ module Analytics
'Termo de Recebimento' => %w[termo_de_recebimento],
'Foto da Fachada' => %w[foto_da_fachada],
'Relatório de visita' => %w[relatorio_de_visita visit_report],
'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base]
'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base],
# Campo do espelho que o modelo antigo do cliente ainda não tinha. Entra
# DEPOIS das colunas do modelo para não deslocar o layout A..AT dele.
'Protocolo de Entrega' => %w[protocolo_de_entrega]
}.freeze
# Quadro do RESUMO — linhas fixas do modelo.
@@ -145,20 +166,24 @@ module Analytics
# Todas as visitas do rastreio para as NFs da operação (inclui repetidas,
# como no dump original do SimpliRoute).
def linhas_rastreio
gade = conn.quote_table_name(@tabela)
sql = <<~SQL
SELECT r.*
FROM #{conn.quote_table_name(Entrega.table_name)} r
WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
ORDER BY r.reference_id, r.checkout NULLS LAST
SQL
conn.select_all(sql).to_a
@linhas_rastreio ||= begin
gade = conn.quote_table_name(@tabela)
sql = <<~SQL
SELECT r.*
FROM #{conn.quote_table_name(Entrega.table_name)} r
WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
ORDER BY r.reference_id, r.checkout NULLS LAST
SQL
completar_serie(conn.select_all(sql).to_a)
end
end
# { cabeçalho do modelo => coluna real do rastreio (ou nil) }.
def colunas_rastreio
existentes = conn.columns(Entrega.table_name).map(&:name)
RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
@colunas_rastreio ||= begin
existentes = conn.columns(Entrega.table_name).map(&:name)
RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
end
end
# ── Aba RESUMO ───────────────────────────────────────────────
@@ -203,11 +228,31 @@ module Analytics
ActiveRecord::Base.connection
end
# O espelho tem a coluna do número de série mas o sync NUNCA a preenche
# (0 de ~6.000 linhas em fev/mar/mai/jul/2026) e a tabela é read-only —
# quem busca o dado na API é o SincronizarSeriesAparelhoJob, que grava em
# series_aparelho. Aqui só completamos as linhas em que o espelho veio
# vazio: se um dia o sync passar a preencher, o valor dele continua valendo.
def completar_serie(linhas)
coluna = colunas_rastreio['Número de Série da Base (Aparelho)']
return linhas if coluna.nil?
pendentes = linhas.reject { |r| r[coluna].present? }
return linhas if pendentes.empty?
series = SerieAparelho.por_tracking(pendentes.map { |r| r['tracking_id'] })
pendentes.each { |r| r[coluna] = series[r['tracking_id']] if series[r['tracking_id']] }
linhas
end
# SELECT das colunas whitelisted; ausentes viram NULL com o mesmo alias.
# O nome real pode diferir da chave (ver ALIAS_GADE) — o alias no SELECT é
# sempre a chave, então quem lê a linha não precisa saber da diferença.
def selects_gade
existentes = conn.columns(@tabela).map(&:name)
COLUNAS_GADE.keys.map do |c|
existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}"
real = (ALIAS_GADE[c] || [c]).find { |cand| existentes.include?(cand) }
real ? "g.#{conn.quote_column_name(real)} AS #{c}" : "CAST(NULL AS text) AS #{c}"
end.join(', ')
end

View File

@@ -66,6 +66,9 @@ module Analytics
# Cabeçalho da ENTREGAS
@cab_azul = st.add_style(bg_color: '1155CC', fg_color: 'FFFFFF', b: true)
@cab_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true)
# Hiperlink (fotos/comprovantes do SimpliRoute) — azul sublinhado padrão.
@link = st.add_style(fg_color: '0563C1', u: true)
end
# ── RESUMO ───────────────────────────────────────────────────
@@ -183,19 +186,38 @@ module Analytics
# Dump cru do rastreio (todas as visitas das NFs da operação), com o
# cabeçalho exato do modelo — sem estilo, como no original; cabeçalho sem
# coluna correspondente sai vazio.
#
# As colunas de foto (Nota Fiscal, Termo de Recebimento, Foto da Fachada,
# Relatório de visita) guardam URLs do S3 do SimpliRoute: viram HIPERLINK
# clicável (azul sublinhado), para o cliente abrir o comprovante direto do
# Excel em vez de copiar e colar a URL.
def aba_simpliroute(wb)
colunas = @planilha.colunas_rastreio
linhas = @planilha.linhas_rastreio
wb.add_worksheet(name: 'SimpliRoute') do |sheet|
sheet.add_row(colunas.keys)
linhas.each do |r|
sheet.add_row(colunas.values.map { |col| col ? texto(r[col]) : '' },
types: Array.new(colunas.size, :string))
valores = colunas.values.map { |col| col ? texto(r[col]) : '' }
sheet.add_row(valores, types: Array.new(colunas.size, :string))
linkar_urls(sheet, valores)
end
sheet.auto_filter = "A1:#{Axlsx.col_ref(colunas.size - 1)}#{linhas.size + 1}"
end
end
# Transforma em hiperlink toda célula da linha recém-adicionada cujo valor
# seja uma URL http(s) — mesma checagem do foto_url do OperacaoMetricas,
# para não jogar lixo dentro do <hyperlink> do arquivo.
def linkar_urls(sheet, valores)
linha = sheet.rows.size
valores.each_with_index do |valor, i|
next unless valor.to_s.match?(%r{\Ahttps?://\S+\z}i)
sheet.add_hyperlink(location: valor, ref: "#{Axlsx.col_ref(i)}#{linha}")
sheet.rows.last.cells[i].style = @link
end
end
def texto(valor)
valor.respond_to?(:strftime) ? valor.strftime('%d/%m/%Y %H:%M') : valor.to_s
end

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,147 @@
# 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
# O EXISTS espelha o `ConsolidacaoMotorista.ativos` de `registros`, e por
# PAR (consolidação, motorista) — filtrar só por nome não basta: o
# mesmo motorista pode estar ativo numa consolidação do período e
# arquivado em outra, e aí os lançamentos da arquivada entravam nesta
# contagem enquanto o R$ da mesma linha os excluía. Arquivar motorista
# é reversível e NÃO apaga consolidacao_entregas (só o excluir apaga).
ConsolidacaoEntrega.where(consolidacao_id: ids,
motorista_nome: registros.map(&:motorista_nome).uniq)
.where(<<~SQL.squish)
EXISTS (SELECT 1 FROM consolidacao_motoristas cm
WHERE cm.consolidacao_id = consolidacao_entregas.consolidacao_id
AND cm.motorista_nome = consolidacao_entregas.motorista_nome
AND cm.arquivado_em IS NULL)
SQL
.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,26 @@
# app/services/notificacao/cliente_twilio.rb
#
# Monta o Twilio::REST::Client com timeout. Sem timeout, uma saída de internet
# bloqueada no servidor pendura o worker Puma até o proxy cortar a requisição —
# e o botão "enviar teste" da tela ficaria girando sem resposta.
#
# O construtor com http_client é posicional na twilio-ruby 7.x
# (username, password, account_sid, region, http_client). Se a assinatura mudar
# numa atualização da gem, cai no construtor simples em vez de quebrar o envio.
module Notificacao
module ClienteTwilio
TIMEOUT = 15
def self.montar(sid, token)
require 'twilio-ruby'
begin
http = Twilio::HTTP::Client.new(timeout: TIMEOUT)
Twilio::REST::Client.new(sid, token, nil, nil, http)
rescue ArgumentError, NameError => e
Rails.logger.warn("[ClienteTwilio] sem timeout customizado (#{e.class}: #{e.message})")
Twilio::REST::Client.new(sid, token)
end
end
end
end

Some files were not shown because too many files have changed in this diff Show More