Compare commits

...

19 Commits

Author SHA256 Message Date
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
56 changed files with 4257 additions and 303 deletions

View File

@@ -45,6 +45,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 +66,15 @@ 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
APP_NAME=Reem Logística

485
README.md
View File

@@ -1452,13 +1452,47 @@ feito manualmente para entregar ao cliente.
ENTREGAS azul `1155CC` (AT) + vermelho `C00000` (UZ), quadros do RESUMO em
`002060`/`0070C0`/`C00000`/`A5A5A5`, status verde/laranja/azul, títulos 16pt, performance em
itálico %, cabeçalhos mesclados. Validado offline com caxlsx + LibreOffice.
- **Links das fotos são hiperlinks clicáveis** (azul sublinhado): Nota Fiscal, Termo de
Recebimento, Foto da Fachada e Relatório de visita abrem o comprovante direto do Excel.
Qualquer célula que seja URL `http(s)` vira link (`#linkar_urls`).
- Arquivos: `Analytics::PlanilhaEntregas` (dados) + `Analytics::PlanilhaEntregasXlsx` (binário,
caxlsx), rota `operacoes_planilha_baixar`, action `baixar_planilha`.
- ⚠️ **Ressalvas:** nomes de coluna das tabelas `gade_entregas_*`/rastreio foram deduzidos com
fallback seguro (coluna inexistente sai **vazia**, sem quebrar o layout) — conferir o primeiro
download real e ajustar `COLUNAS_GADE`/`RASTREIO_COLUNAS` se algo sair em branco. Os 2
**gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é controle
manual (sai "A iniciar").
#### Nomes de coluna — conferidos no banco real (22/07/2026)
Os nomes eram **deduzidos** e 9 cabeçalhos nunca casavam, saindo vazios em silêncio. Os nomes
reais foram conferidos com `Entrega.column_names` e estão como 1º candidato em
`RASTREIO_COLUNAS`; os chutes antigos continuam na lista como rede de segurança.
- Espelho usa o padrão do export pt-BR: `codrivers`, `responsible_person`, `advance`,
`start_of_time_window_1..2`, `end_of_time_window_1..2`, `required_skills`, `optional_skills`,
`comments`. Corrigidos — antes eram `copilots`, `receiver`, `early`, `window_start`, etc.
- Tabelas `gade_entregas_*` gravam a geo como **`lat`/`long`** (não `latitude`/`longitude`) —
as duas colunas da aba ENTREGAS saíam vazias. Ver `ALIAS_GADE`.
- **`Load 4` não existe** no espelho (só `load`, `load_2`, `load_3`) — sai vazia de propósito.
- Coluna **`protocolo_de_entrega`** existe no espelho e não estava em lugar nenhum: entrou como
última coluna (AU), **depois** do layout AAT do modelo, para não deslocar nada do cliente.
- ⚠️ Vazias por **falta de dado na origem**, não por bug: `contact_phone`, `account_id`,
`account_name` (colunas existem no espelho e o sync nunca preenche — a API do SimpliRoute
TEM o valor, conferido na NF 79093) e `codrivers`/`required_skills`/`optional_skills`/
`contact_email`/`load_2`/`load_3` (vazios também no relatório baixado direto do SimpliRoute).
- ⚠️ Os 2 **gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é
controle manual (sai "A iniciar").
#### 🔄 Número de série da base (aparelho) — job em background
A coluna AT da aba SimpliRoute é o único campo do modelo que o espelho **nunca** traz (0 de
~6.000 linhas em fev/mar/mai/jul/2026), e não dá para deduzir da `gade_entregas_*`: os seriais
de `num_serie_base` são de NFs **disjuntas** das que têm série no SimpliRoute (0 de 203 batem).
O dado só existe na API, em `extra_field_values` — o mesmo hash das fotos do motorista.
- `SincronizarSeriesAparelhoJob` varre a API por DATA (a API não filtra intervalo; cada dia são
~4 MB / ~9 s) em lotes de 6 threads e grava em **`series_aparelho`** (tabela nossa — o espelho
é read-only). Um dia que não responde vira aviso, não derruba a rodada.
- `Analytics::PlanilhaEntregas#completar_serie` completa **só** as linhas em que o espelho veio
vazio: se um dia o sync passar a preencher, o valor do espelho continua ganhando.
- Agendado em `config/schedule.rb` às **01h** (últimos 45 dias). Backfill de meses antigos:
`rake "simpli_route:series_operacao[gade_entregas_ubs_sudeste_jul_2026]"` ou
`rake "simpli_route:series_periodo[2026-07-01,2026-07-31]"`.
- Se a rodada varrer visitas e não achar **nenhuma** série, o log lista as chaves de
`extra_field_values` que vieram — é o sinal de que o campo mudou de nome e basta acrescentá-lo
em `CAMPOS_SERIE` (foi chute de nome de campo que causou o bug acima).
### 📊 Dashboard financeiro
- **Paginação nas tabelas de pagamentos** ("realizados" e "pendentes"): 10 linhas/página com
@@ -1638,3 +1672,444 @@ app/javascript/controllers/validacao_controller.js (chip usa SVG, não emoji)
</details>
---
<details>
<summary><strong>🔁 NF com mais de um lançamento + varredura de responsividade (2122/07/2026)</strong></summary>
## Parte 1 — Editar Lançamento: quando a mesma NF tem 2 visitas
### 🎯 O problema real
Quando um plano é **duplicado** no SimpliRoute, nasce uma **visita nova** (outro `tracking_id`) com a
**mesma NF**, e a antiga continua existindo. Caso que motivou tudo: **NF 82891** com visita em
**17/07** (pendente, motorista Thiago Rabello Bittencourt) e outra em **21/07** (sucesso, sem
motorista). A tela mostrava **uma só** — e depois, por regressão, **nenhuma**.
### 🔴 Causas corrigidas (foram 7, em camadas)
1. **`Entrega.por_nf(nf).first`** — pegava uma linha só, sem `ORDER BY`. Agora carrega todas e a
tela lista as ocorrências para o ADM escolher qual editar.
2. **`SimpliRoute::Client#resolver_id` abortava na ambiguidade** (`"Mais de uma visita para a NF…"`)
em vez de deixar escolher. A tela de edição não passa mais por ele.
3. **As datas vinham só do espelho local.** Como a API **não busca NF por intervalo**, a visita de
17/07 era inalcançável se o painel só conhecia a de 21/07. Entraram campos **De/Até** + botão
**"Últimos 30 dias"** (varredura dia a dia, teto de 62 dias).
4. **Um dia com falha derrubava a busca inteira** → agora é *fail-soft* por dia: registra em
`falhas` e segue. A tela informa o período consultado e quais dias falharam.
5. **`carregar?` faltando em `EdicaoLancamentoPolicy`** → o Pundit levantava `NoMethodError` (500),
o `fetch` recebia HTML e o JS reportava "erro de conexão". Falhou **fechado** (negou acesso),
sem brecha de segurança.
6. **Tela em branco:** `renderOcorrencias()` montava a lista inteira mas **nunca removia a classe
`hidden`** do container. O conteúdo estava no DOM (contador já dizia "2 lançamentos"), invisível.
7. **Fuso horário:** `new Date('2026-07-15')` é meia-noite **UTC** e, em UTC3, o `toLocale`
devolvia **14/07**. Datas puras agora são formatadas direto do texto ISO; horários de checkout
continuam convertendo (correto para instante).
### 🔑 Descobertas sobre a API do SimpliRoute (medidas contra a API real, 21/07/2026)
| Parâmetro | Resultado |
|---|---|
| **`&search=<NF>`** | ✅ **Funciona e não é documentado.** Um dia cai de **3,8 MB / ~9 s** (1879 visitas) para **~1 KB / ~0,6 s**. Varredura de 31 dias em lotes paralelos: **3,4 s / 3,2 KB** |
| `reference`, `reference_id`, `q`, `title`, `reference__*` | ❌ **Ignorados em silêncio** — devolvem 200 com o dia inteiro |
| `planned_date_from/to`, `since/until`, `__gte/__lte`, `date_from/to` | ❌ **Não existe filtro de intervalo** — todos devolveram 2469, idêntico ao controle **sem parâmetro nenhum** |
| `GET /v1/routes/visits/` sem `planned_date` | ⚠️ Devolve um **conjunto padrão (~2469)** que **não cobre o histórico** — para a NF 82891 voltava só a visita de 21/07 e escondia a de 17/07 |
> ⚠️ **Lição:** parâmetro não registrado no backend Django é **ignorado sem erro**. "Voltou 200 com
> resultados" **não** prova que filtrou — a prova é a **contagem diminuir** em relação ao dia sem o
> filtro. Ferramenta: **`bin/sondar_busca_nf --nf <n> --data <YYYY-MM-DD> [--sem-data]`** (só GET).
---
## Parte 2 — Responsividade
### 🎯 A raiz comum
Os breakpoints do Tailwind (`md:`, `lg:`, `xl:`) enxergam a **janela**, mas o conteúdo perde
**256px** para a sidebar (`main.md:ml-64`). **A mesma janela de 1280px tem duas larguras** conforme
o menu esteja aberto ou recolhido — e grades de colunas fixas não ficam sabendo. Daí "quando a barra
de menu é acionada, quebra o layout". Solução: **`flex-wrap` / `auto-fit + minmax`**, que reagem ao
espaço **real**.
### 🔧 Corrigido
| Tela | Antes | Sintoma | Depois |
|---|---|---|---|
| `consolidacoes/index` | `md:grid-cols-6` | Campos a ~150px; texto do botão **"Filtrar" vazava** para fora do fundo | `flex-wrap` + `basis-*` |
| `dashboard/index` | `xl:grid-cols-5` | **"R$ 82.692,"** — valor cortado pelo `overflow-hidden` do card | `auto-fit,minmax(13rem,1fr)` |
| `consolidacao_entregas/revisar` | `md:grid-cols-7` | 7 cards de **~55px**, destruindo "Extraordinária"/"Termo Especial" | `auto-fit,minmax(9rem,1fr)` |
| `configuracoes` + `admin/configuracoes` | `lg:grid-cols-4` | ~128px úteis para valores em moeda | `auto-fit,minmax(14rem,1fr)` |
**Mantidos de propósito:** `validar.html.erb` (`lg:grid-cols-4` é a divisão 3:1 lista/Resumo, não
grade de cards) e os `xl:grid-cols-3` de gráficos — nesses o conteúdo encolhe sem cortar.
### 📐 Passo 2 (validar) — cabeçalho fixo
Medido a 1280×577: **442px de cabeçalho contra 67px de lista** (menos que um card).
- Voltar + título + progresso passaram a **uma faixa só**; o card de progresso (70px de moldura para
uma barra de 12px) virou linha de 8px.
- **"Adicionar lançamento"** entrou na barra de ações via `order` do flex — sem mover os ~170 linhas
de modais que vivem dentro do `data-controller`.
- **Pilares (Normal/Retirada/Bônus/Desconto/Extra) continuam SEMPRE visíveis**, apenas **esmaecidos**
(`opacity-40`) enquanto não há seleção, com contador e "limpar" aparecendo ao selecionar.
⚠️ Uma versão intermediária os **escondia** até haver seleção — revertido: são a ação principal da
tela e sumir com eles esconde o que dá para fazer de quem ainda não sabe que precisa selecionar.
- Legibilidade preservada: título `text-2xl`, subtítulo e progresso `text-sm`, números em branco/negrito.
**Resultado:** cabeçalho **442px → 210px**, lista **67px → 299px**.
### 📂 Arquivos
```
app/controllers/admin/edicao_lancamentos_controller.rb # lista ocorrências, período, fail-soft, carregar
app/policies/edicao_lancamento_policy.rb # + carregar? (era o 500)
app/services/simpli_route/client.rb # visitas_da_data(data, busca:) → &search=
app/views/admin/edicao_lancamentos/show.html.erb # lista de ocorrências, De/Até, avisos, fuso
config/routes.rb # + get :carregar
bin/sondar_busca_nf # NOVO — sondagem de filtros da API (só GET)
app/views/consolidacao_entregas/validar.html.erb # cabeçalho compacto + barra de ações
app/javascript/controllers/validacao_controller.js # atualizarSelecao / limparSelecao
app/views/consolidacao_entregas/revisar.html.erb # grid-cols-7 → auto-fit
app/views/consolidacoes/index.html.erb # filtros → flex-wrap
app/views/dashboard/index.html.erb # KPIs → auto-fit
app/views/configuracoes/index.html.erb # preços → auto-fit
app/views/admin/configuracoes/index.html.erb # idem (arquivo distinto, também vivo)
```
### ⚠️ Pendências e alertas
- **Tailwind vem do Play CDN** (`cdn.tailwindcss.com`, em `application.html.erb:15`), que gera CSS no
navegador em tempo real. A documentação oficial **desaconselha em produção** (~380KB bloqueando a
renderização, recompilação a cada carregamento). O **`tailwind.config.js` do repositório não está
sendo usado** — a config real está embutida no layout (linha 17), e a gem `tailwindcss-rails` está
no Gemfile sem servir CSS.
- **Não existe teste para `Admin::EdicaoLancamentosController`** (`spec/` não tem nada de
`edicao_lancamento`). Duas das quebras acima — policy faltando e `hidden` não removido — seriam
pegas por um teste de request/sistema em segundos, sem custar deploy.
- **Token do SimpliRoute:** se passou por chat/e-mail, **rotacione** em `app2.simpliroute.com`.
> **Sem migration e sem gem nova** — controller, policy, service, views e JS.
> ⚠️ Reiniciar o Puma após o deploy (cache de classes/views).
</details>
---
<details>
<summary><strong>✉️ Notificações e E-mail configuráveis pela tela — SMTP + WhatsApp (11/08/2026)</strong></summary>
> ⚠️ **STATUS: implementado, ainda NÃO executado.** Nada aqui foi rodado contra um banco nem
> contra os servidores reais (Gmail/Twilio) — não há Ruby nem Postgres na máquina de
> desenvolvimento. Foi conferida a sintaxe de todos os arquivos `.rb` e `.erb` e validado o
> algoritmo de normalização de telefone em Ruby puro. **A migration, a suíte e o envio real
> continuam pendentes** — roteiro no fim desta seção.
### 🎯 O problema
Servidor de e-mail e credenciais do Twilio viviam **só no `.env`**: trocar a senha de app do Gmail
ou o número remetente exigia editar o arquivo no servidor e **reiniciar o container**. O ADM não
tinha como fazer nada disso pela interface.
Pior: as chaves `notificacao_whatsapp` e `notificacao_email` existiam em `configuracoes` mas
**nunca tiveram UI** — `Admin::ConfiguracoesController#index` filtra por `CHAVES_MOEDA`. Ligar
notificação só era possível pelo `rails console`.
### 🆕 A tela
**Configurações → card "Notificações e E-mail"** (`/admin/configuracao_notificacao`).
**Só `admin`** — `ConfiguracaoNotificacaoPolicy` é mais restrita que `ConfiguracaoPolicy`, que
libera `index?` para gerente: aqui ficam senha de e-mail e token do Twilio.
O acesso é **exclusivamente pelo card dentro de Configurações** — de propósito não há item no menu
lateral, para não expor um atalho de credenciais na navegação de todo dia.
| Bloco | Campos |
|---|---|
| **Servidor de e-mail (SMTP)** | ativo, servidor, porta, usuário, senha, autenticação, domínio, e-mail e nome do remetente + toggle "avisar motoristas por e-mail" |
| **WhatsApp (Twilio)** | ativo, Account SID, Auth Token, número remetente |
| **Destinatários administrativos** | e-mail do admin, WhatsApp do admin |
Três botões: **Salvar**, **Salvar e enviar e-mail de teste**, **Salvar e enviar WhatsApp de teste**.
### ⚙️ Como funciona (técnico) — os 6 pontos não-óbvios
**1. Hierarquia banco > `.env`, sem quebrar nada.** `ConfiguracaoNotificacao#smtp_settings` devolve
**`nil`** quando não está pronto. O ActionMailer faz `.merge(options || {})` por cima do que o
`config/initializers/smtp.rb` montou no boot — então o fallback para o `.env` é **automático**.
Enquanto os toggles estiverem desligados, o comportamento é **idêntico ao de antes desta tela**.
**2. `default delivery_method_options:`, NÃO um `before_action`.** Um callback que mexesse em
`message.delivery_method` seria **descartado**: `ActionMailer::Base#mail` roda *depois* dos
callbacks e chama `wrap_delivery_behavior!`, que reconfigura a mensagem. O
`delivery_method_options` é lido *dentro* do próprio `mail()`.
**3. `proc`, NÃO lambda.** O Devise avalia o `default from:` com `instance_eval(&proc)`, que passa
1 argumento. Um `-> { }` de aridade 0 estouraria **`ArgumentError` em todo "esqueci minha senha"**.
**4. `config.parent_mailer = 'ApplicationMailer'`** no `devise.rb` — sem isso o reset de senha
continuaria preso ao `.env`. Efeito colateral aceito: os e-mails do Devise passam a usar
`app/views/layouts/mailer.html.erb`.
**5. Segredos cifrados sem `master.key`.** Senha SMTP e Auth Token vão para colunas
`*_cifrado` (AES-256-GCM) via `AtributoCifrado`, com chave derivada do `secret_key_base`. **Não** se
usou ActiveRecord Encryption: exigiria 3 chaves novas, dependeria da ordem dos initializers e
estouraria `Errors::Decryption` na leitura. Aqui o reader faz `rescue → nil`, o app **degrada para
o `.env`** e a tela mostra um banner amarelo pedindo para redigitar.
**6. Testes com `deliver_now` e `raise_delivery_errors = true` forçado.** O `development.rb` define
`raise_delivery_errors = false` e o adapter do ActiveJob é `:async` (thread in-process) — com
`deliver_later` **o teste "passaria" em silêncio mesmo com a senha errada**.
### 🩹 Bug pré-existente corrigido de passagem
`NotificacaoService` mandava `to: "whatsapp:#{user.telefone}"` com o telefone **cru do cadastro**.
Um telefone gravado como `(11) 92005-1157` vira `whatsapp:(11) 92005-1157`, o Twilio devolve
**21211** — e o `rescue` engolia. **Provavelmente nenhum WhatsApp a motorista jamais chegou.**
Agora passa por `ConfiguracaoNotificacao.canal`, que normaliza para E.164.
> Casos cobertos: `11 920051157`, `(11) 92005-1157`, `011 …`, `+55 11 …`, `whatsapp:+55…` →
> `+5511920051157`. O prefixo `55` só é removido quando sobra número demais — senão quebraria o
> **DDD 55** (Santa Maria/RS), onde `55991234567` já é o número completo.
### 🔐 Segurança
- A senha gravada **nunca volta para o HTML** (`password_field value: nil`). Campo em branco
significa "mantenha a atual" — salvar sem redigitar não apaga o que está lá.
- `AuditoriaLog` registra a mudança com `acao: 'editar_notificacoes'`, mas grava apenas
`smtp_password_definida: true/false`. **Nunca a senha nem o token** — `dados_novos` é exibido em
`/admin/auditoria_logs`, que **gerente também acessa**.
- Os erros do Twilio vêm traduzidos (63003/63015 = falta o `join <palavra>` do sandbox, 21608 =
conta trial só envia a número verificado, 20003 = SID/token inválidos…), para o ADM resolver
sozinho sem abrir o log do container.
### 🆕 Migration adicionada (rodar `db:migrate`)
```
20260811000001_create_configuracao_notificacoes.rb
```
Tabela **singleton** (índice único em `singleton_guard` — impede dois workers Puma criarem linhas
concorrentes). Copia as flags antigas de `configuracoes` e **não** importa credenciais do `.env`:
os campos nascem vazios e o fallback segue mandando até alguém preencher a tela.
### 📂 Arquivos
```
db/migrate/20260811000001_create_configuracao_notificacoes.rb # NOVO — tabela singleton
app/models/configuracao_notificacao.rb # NOVO — smtp_settings, credenciais, E.164
app/models/concerns/atributo_cifrado.rb # NOVO — AES-256-GCM sem master.key
app/policies/configuracao_notificacao_policy.rb # NOVO — show?/update? = admin
app/controllers/admin/configuracao_notificacoes_controller.rb # NOVO — show/update + params[:acao]
app/views/admin/configuracao_notificacoes/show.html.erb # NOVO — a tela
app/services/notificacao/resultado.rb # NOVO — ok?/mensagem/detalhe → flash
app/services/notificacao/teste_email.rb # NOVO — erros SMTP em português
app/services/notificacao/teste_whatsapp.rb # NOVO — códigos Twilio em português
app/services/notificacao/cliente_twilio.rb # NOVO — client com timeout de 15s
app/mailers/teste_mailer.rb · app/views/teste_mailer/teste.html.erb # NOVO
app/services/notificacao_service.rb # lê do banco; unifica os 2 pares duplicados
app/mailers/application_mailer.rb # default from: / delivery_method_options: proc
app/mailers/consolidacao_mailer.rb # removido o `default from:` que anulava o proc
config/initializers/devise.rb # + parent_mailer
config/initializers/smtp.rb # vira fallback (só comentário)
config/initializers/inflections.rb # + irregular 'notificacao'
config/routes.rb # + resource :configuracao_notificacao
app/views/admin/configuracoes/index.html.erb # + card "Notificações e E-mail" (só admin)
app/models/configuracao.rb # marca notificacao_* como obsoletas
.env.example # hierarquia banco > .env + NOTIFICACAO_SECRET
spec/models/configuracao_notificacao_spec.rb # NOVO
spec/policies/configuracao_notificacao_policy_spec.rb # NOVO
spec/requests/admin/configuracao_notificacoes_spec.rb # NOVO
spec/models/table_names_spec.rb # + ConfiguracaoNotificacao
```
### ⏳ Pendente de execução — roteiro
```bash
# 1. Migrar
docker-compose exec app bundle exec rails db:migrate
# 2. Suíte
docker-compose exec app bundle exec rspec spec/models spec/policies spec/requests
# 3. Confirmar a assinatura da gem (não pôde ser verificada fora do container)
docker-compose exec app bundle exec rails runner \
'p Twilio::HTTP::Client.instance_method(:initialize).parameters'
```
4. **Permissão:** logar como **gerente** → o card não aparece e `/admin/configuracao_notificacao`
redireciona com "Você não tem permissão". Como **admin** → a tela abre.
5. **E-mail:** `smtp.gmail.com`, porta 587, **Senha de app de 16 caracteres** (não a senha da
conta), marcar "Ativar envio de e-mail" → **Salvar e enviar e-mail de teste**. Conferir o spam.
6. **WhatsApp:** SID / Auth Token / número remetente do Twilio, marcar "Ativar WhatsApp" →
**Salvar e enviar WhatsApp de teste**. ⚠️ **No sandbox, o número que vai RECEBER precisa antes
mandar `join <sua-palavra>`** para o número do sandbox — sem esse opt-in volta 63003/63015.
7. **Não regressão:** com os toggles desligados, finalizar uma consolidação e conferir que nada
mudou; e testar o **"Esqueci minha senha"**, que trocou de mailer pai.
### ⚠️ Alertas
- **Rotação do `SECRET_KEY_BASE` torna senha e token ilegíveis.** O sistema não quebra (volta ao
`.env` e avisa na tela), mas os dois campos precisam ser redigitados. Para desacoplar, defina
**`NOTIFICACAO_SECRET`** no `.env` com uma string longa e **fixa**.
- **Não cachear a config em `Rails.cache`:** o `production.rb` usa `:memory_store`, que é por
processo — a tela pareceria "não salvar" para os outros workers. É 1 `SELECT` por e-mail.
- `app/views/configuracoes/index.html.erb` (fora do `admin/`) **não recebeu o card**: não tem rota
e é código morto — o vivo é `app/views/admin/configuracoes/index.html.erb`.
> **Migration nova** (`db:migrate` obrigatório) e **sem gem nova** — `twilio-ruby` já estava no
> Gemfile. ⚠️ Reiniciar o Puma após o deploy.
</details>
---
<details>
<summary><strong>💰 Entrega sem sucesso entra no pagamento + aba Consolidado no ranking (2021/08/2026)</strong></summary>
> ⚠️ **STATUS: no ar no ambiente de teste e conferido com dados reais** (17 motoristas,
> 4.976 entregas de agosto). O que **continua pendente é a suíte** — não há Ruby nem Postgres na
> máquina de desenvolvimento, então os specs novos foram validados só na sintaxe. Roteiro no fim
> desta seção.
### 🎯 O problema
A Reem **paga a entrega sem sucesso**: o motorista foi até o local, teve o deslocamento e o custo,
e o insucesso é só o desfecho da visita. A **consolidação** já tratava assim desde sempre
(`Entrega::STATUS_ATENDIDO` = `completed` + `failed`), mas o **dashboard não** — mostrava um valor
**menor que o do fechamento**, e ninguém sabia explicar a diferença.
### 🔴 Correções
**1. Dashboard principal contava só as concluídas** — `dashboard_controller.rb` (commit `cebd6e2`)
A base financeira era `Entrega.pagas` (só `completed`). Passou a ser `.atendidas`
(`completed` + `failed`, com checkout) — **o mesmo recorte que a consolidação considera elegível**,
que é justamente o ponto: tela e fechamento agora partem do mesmo conjunto.
**2. A falhada caía no período errado** — mesmo commit
As falhas eram filtradas por `planned_date`, e as concluídas por `checkout`. **Falhada TEM
checkout** (o motorista fechou a visita com motivo de insucesso), então o eixo correto é o mesmo
das concluídas. Uma entrega planejada em 31/07 e fechada em 01/08 pertence a agosto — como a
consolidação sempre entendeu.
**3. O painel do motorista ficou para trás** — `motorista/dashboard_controller.rb` (commit `4965931`)
Continuava em `Entrega.pagas` + `no_periodo` (planned_date), ou seja, a lógica antiga inteira.
O motorista via **menos do que ia receber** — no mês corrente, 124 entregas (~R$ 2.232,00)
invisíveis — e a diferença só aparecia no fechamento. Passou para `.atendidas` +
`no_periodo_checkout`, e o rótulo *"N entregas feitas e confirmadas"*, que mentia sobre o número
novo, virou:
```
R$ 20,00
2 entregas atendidas
1 entregues · 1 sem sucesso (pagas também)
```
A segunda linha não é enfeite: sem ela o motorista vê um total maior e não tem como conferir de
onde veio.
**4. A barra do ranking contradizia a ordem do ranking** — `_ranking_motoristas.html.erb` (commit `e9db6b7`)
A barra era proporcional à **quantidade**, mas o card é ordenado por **valor**. Na aba Estimado dá
no mesmo (valor = qtd × preço); na Consolidada, bônus/retirada/termo mudam o preço unitário e a
barra do 3º (290 entregas, R$ 5.250) saía **maior** que a do 2º (261 entregas, R$ 5.260).
Invertia em três pontos da lista. Agora escala pelo valor, que é o número que ordena.
### 🆕 Aba "Consolidado" no ranking de motoristas
O card **Motoristas** ganhou duas abas — a dúvida recorrente era justamente *"esse ranking mostra o
estimado ou o consolidado?"*:
| Aba | O que mostra | De onde vem |
|-----|--------------|-------------|
| **Estimado** | entregas atendidas × preço da entrega | espelho de rastreio (`Entrega.atendidas`) |
| **Consolidado** | valor **realmente fechado** + quantidade exata de entregas | `consolidacao_motoristas` / `consolidacao_entregas` |
Os números divergem **de propósito**: o estimado cobre tudo que foi atendido no período; o
consolidado, só o que já entrou em consolidação **finalizada**, com bônus/desconto/retirada
aplicados. Cada aba diz na tela de onde vem o seu número.
Dois detalhes decidem se a quantidade sai certa:
- **`DISTINCT tracking_id`, não contagem de linhas.** Uma entrega pode ter vários pilares —
Normal + Bônus + Retirada são **3 linhas** em `consolidacao_entregas` para **1 entrega**.
Contar linhas inflaria o número.
- **Só motoristas ativos.** O ranking parte de `@fin_por_motorista` (que vem de
`ConsolidacaoMotorista.ativos`), então **arquivado não aparece** — ele saiu do fechamento e não
tem valor a exibir. Isso também garante que a aba soma exatamente o KPI "Custo total" do topo.
O markup da lista virou a partial `_ranking_motoristas.html.erb`, usada pelas duas abas: os dois
conjuntos têm a mesma forma (`:nome`, `:valor`, `:entregas`) e duplicar o HTML faria as abas
divergirem visualmente na primeira alteração.
### ✅ Conferência com dados reais (teste.reemtransportes.com.br, 21/08/2026)
**Dashboard principal — período 01/08 a 21/08:**
| Card | Na tela | Confere |
|------|---------|---------|
| Valor Estimado | R$ 89.568,00 | 4.976 × R$ 18,00 exato |
| — subtítulo | 4.976 entregas atendidas | 4.852 + 124 |
| Total Entregas | 4.977 | 4.976 atendidas + 1 pendente |
O teste decisivo: 4.852 concluídas × R$ 18 dariam **R$ 87.336,00**. A tela mostra **R$ 89.568,00** —
exatamente **R$ 2.232,00 a mais, que são as 124 sem sucesso**. O insucesso entra no dinheiro, não
só na contagem.
**Consistência interna** (as falhas entram em todo lugar, não só no card):
- Ranking por motorista: os 17 motoristas somam **exatamente 4.976**; se contasse só sucesso daria
4.852.
- Gráfico "Evolução do custo": a série diária soma **R$ 69.408,00**, idêntico ao valor estimado do
período 0114/08 — as falhas caem nos dias certos (eixo checkout).
- Sem dupla contagem: o scope `pendentes` exclui `failed`, então entregue / pendente / sem sucesso
não se sobrepõem.
**Aba Consolidado — reconciliação com os KPIs:**
| | Soma da aba | KPI do topo |
|---|---|---|
| Valores | **R$ 72.508,00** | R$ 72.508,00 ("Custo total") ✅ |
| Entregas | **3.858** | 3.858 ("entregas classificadas") ✅ |
Bate à vírgula e à unidade. Isso explica também a diferença **3.858 consolidadas × 3.856 atendidas**:
são 2 entregas que entraram no fechamento sem estar na janela de checkout do período (apontamento
manual de NF fora do período, ou consolidação que extrapola as datas). **Não é erro de contagem —
são bases diferentes**, e agora dá para ver as duas lado a lado.
### 🧪 Specs — ⏳ pendentes de execução
`spec/requests/dashboard_spec.rb` (+4 casos) e `spec/requests/motorista_dashboard_spec.rb` (novo,
5 casos). Usam o harness `spec/support/espelho_rastreio.rb`, que monta uma cópia descartável de
`db_reem_simplerout_2026` no banco de teste — sem ele só daria para mockar o método, o que não pega
regressão de **SQL**, que é onde moram os bugs de eixo de data.
O que fica travado:
- a sem sucesso soma no valor e na quantidade;
- entra pelo **checkout** (planejada 31/07 + checkout 01/08 → conta em agosto) e sai quando o
checkout cai fora;
- pendente sem checkout não vira dinheiro;
- entrega de outro motorista não vaza para o painel;
- na aba Consolidado: 3 linhas de 2 `tracking_id` = **"2 entregas"**, desconto subtraindo,
arquivado fora da lista, rascunho não entrando.
> As asserções da aba Consolidado são escopadas ao `#ranking-painel-consolidado` via Nokogiri — a
> aba Estimado renderiza o **mesmo markup** (moeda + "N entregas"), então asserção no `body` inteiro
> passaria por acidente.
O painel do motorista não tem filtro de período (é sempre "do dia 1º até hoje"), então o spec
congela a data com `travel_to`; sem isso ele quebraria sozinho ao rodar no dia 1º.
### 📂 Arquivos
```
app/controllers/dashboard_controller.rb (atendidas + eixo checkout; @ranking_consolidado)
app/controllers/motorista/dashboard_controller.rb (atendidas + no_periodo_checkout; quebra do card)
app/models/entrega.rb (scopes atendidas / falhadas / no_periodo_checkout)
app/views/dashboard/index.html.erb (abas Estimado/Consolidado + JS da troca)
app/views/dashboard/_ranking_motoristas.html.erb (NOVO — lista compartilhada pelas duas abas)
app/views/motorista/dashboard/index.html.erb (rótulo "atendidas" + linha da quebra)
spec/requests/dashboard_spec.rb (+ aba Consolidado)
spec/requests/motorista_dashboard_spec.rb (NOVO)
spec/support/espelho_rastreio.rb (harness da tabela externa)
```
> **Sem migration e sem gem nova** — só controllers, views e specs.
### ⏳ Pendente — roteiro
```bash
# 1. Suíte (única coisa que não pôde ser executada)
docker compose exec app bundle exec rspec \
spec/requests/dashboard_spec.rb spec/requests/motorista_dashboard_spec.rb
# 2. Depois do deploy: conferir a barra do ranking na aba Consolidado
# (deve encurtar sempre de cima para baixo)
# 3. Painel do motorista com dado real — logar como motorista no teste e
# conferir a linha "N entregues · N sem sucesso (pagas também)"
```
</details>

View File

@@ -0,0 +1,101 @@
# 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 twilio_account_sid twilio_auth_token twilio_from whatsapp_admin
].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
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,
twilio_account_sid: @config.twilio_account_sid,
twilio_from: @config.twilio_from,
whatsapp_admin: @config.whatsapp_admin,
smtp_password_definida: @config.smtp_password?,
twilio_auth_token_definido: @config.twilio_auth_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

@@ -22,44 +22,42 @@ 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
},
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
@@ -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.
@@ -146,6 +161,210 @@ class Admin::EdicaoLancamentosController < ApplicationController
@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

@@ -76,32 +76,40 @@ 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
# 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)
@motoristas = atendidas.group(:driver)
.count
.sort_by { |_, v| -v }
.map do |driver, qtd|
@@ -113,14 +121,14 @@ class DashboardController < ApplicationController
end
# Por local de entrega (contact_name)
@por_operacao = pagas.group(:contact_name)
@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).
@@ -219,22 +227,39 @@ class DashboardController < ApplicationController
# 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 = ces.group(:motorista_nome).distinct.count(:tracking_id)
@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)
# `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

@@ -7,15 +7,27 @@ module Motorista
def index
nome = current_user.nome
# Card 1 — Valor ESTIMADO (mês atual): entregas pagas × preco_entrega
# Card 1 — Valor ESTIMADO (mês atual): entregas ATENDIDAS × preco_entrega.
#
# Atendida = o motorista FOI ao local e fechou a visita, com sucesso
# ('completed') OU insucesso ('failed') — no insucesso o deslocamento
# aconteceu e teve custo, então a empresa paga os dois. É o MESMO recorte do
# dashboard administrativo e da consolidação (ver Entrega::STATUS_ATENDIDO e
# Entrega.contar_atendidas): usava `pagas` (só concluídas) e o motorista via
# um valor MENOR que o do fechamento, sem entender de onde vinha a diferença.
#
# ⚠️ O eixo é o CHECKOUT (data real da visita), não planned_date: falhada TEM
# checkout, e uma entrega planejada em 31/07 mas fechada em 01/08 pertence a
# agosto — igual ao que a consolidação considera elegível.
@estimado_inicio = Date.current.beginning_of_month
@estimado_fim = Date.current
entregas_mes = Entrega.pagas.da_conta_gade
atendidas = Entrega.atendidas.da_conta_gade
.do_motorista(nome)
.no_periodo(@estimado_inicio, @estimado_fim)
.count
@valor_estimado = entregas_mes * Configuracao.preco_entrega
@entregas_mes = entregas_mes
.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
# Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas
@minhas_consolidacoes = Consolidacao.ativas

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

View File

@@ -0,0 +1,192 @@
# 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
AUTENTICACOES = %w[plain login cram_md5].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 :twilio_account_sid, presence: true, if: :whatsapp_ativo?
validates :twilio_from, presence: true, if: :whatsapp_ativo?
validate :token_twilio_presente, if: :whatsapp_ativo?
# ── Singleton ───────────────────────────────────────────────
def self.instancia
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 / Twilio ───────────────────────────────────────
def whatsapp_pronto?
whatsapp_ativo? && twilio_account_sid.present? &&
twilio_auth_token.present? && twilio_from.present?
end
def whatsapp_habilitado?
whatsapp_ativo? && 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
# 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 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

@@ -121,12 +121,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,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

@@ -0,0 +1,22 @@
# app/policies/configuracao_notificacao_policy.rb
#
# Mais restrito que ConfiguracaoPolicy (que libera index? para gerente): estas
# são credenciais de envio — senha de e-mail e token do Twilio —, então só ADM.
#
# ⚠️ Cada action nova do controller precisa de um método `<action>?` aqui,
# senão o `authorize` estoura.
class ConfiguracaoNotificacaoPolicy < ApplicationPolicy
def show?
admin?
end
def update?
admin?
end
class Scope < Scope
def resolve
scope.all
end
end
end

View File

@@ -4,8 +4,13 @@
# operação sensível e irreversível. Restrita a admin.
# Policy "headless": autorizada com o símbolo :edicao_lancamento (sem model).
class EdicaoLancamentoPolicy < ApplicationPolicy
# ⚠️ Uma entrada por action do controller: o Pundit chama "<action>?" e, se o
# método não existir, levanta NoMethodError (500) — foi o que aconteceu ao
# adicionar `carregar` sem passar por aqui. Ação nova no controller ⇒ linha
# nova aqui.
def show? = admin?
def buscar? = admin?
def carregar? = admin?
def atualizar? = admin?
def historico? = admin?
end

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,6 +166,7 @@ module Analytics
# Todas as visitas do rastreio para as NFs da operação (inclui repetidas,
# como no dump original do SimpliRoute).
def linhas_rastreio
@linhas_rastreio ||= begin
gade = conn.quote_table_name(@tabela)
sql = <<~SQL
SELECT r.*
@@ -152,14 +174,17 @@ module Analytics
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
completar_serie(conn.select_all(sql).to_a)
end
end
# { cabeçalho do modelo => coluna real do rastreio (ou nil) }.
def colunas_rastreio
@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 ───────────────────────────────────────────────
# Agrega as linhas da ENTREGAS por grupo (EMAD/UBS) e coordenadoria:
@@ -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,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

View File

@@ -0,0 +1,27 @@
# app/services/notificacao/resultado.rb
#
# Retorno dos testes de envio. Existe para o controller não precisar saber nada
# sobre SMTP nem sobre a API do Twilio — só perguntar `ok?` e jogar no flash.
module Notificacao
Resultado = Struct.new(:ok, :mensagem, :detalhe, keyword_init: true) do
def self.sucesso(mensagem)
new(ok: true, mensagem: mensagem)
end
def self.falha(mensagem, detalhe = nil)
new(ok: false, mensagem: mensagem, detalhe: detalhe)
end
def ok?
!!ok
end
# O detalhe técnico (mensagem crua do servidor/Twilio) só aparece no erro —
# é o que permite ao admin resolver sozinho sem abrir o log do container.
def para_flash
texto = mensagem.to_s
texto += " [detalhe técnico: #{detalhe.to_s.truncate(180)}]" if !ok? && detalhe.present?
{ ok? ? :notice : :alert => texto }
end
end
end

View File

@@ -0,0 +1,69 @@
# app/services/notificacao/teste_email.rb
#
# Dispara um e-mail de teste com as credenciais que ACABARAM de ser salvas e
# devolve o erro REAL traduzido para português. É o oposto do NotificacaoService,
# que engole as falhas de propósito para não derrubar o fechamento — aqui o
# objetivo é justamente mostrar o problema.
module Notificacao
class TesteEmail
def initialize(config = ConfiguracaoNotificacao.instancia, destinatario: nil, usuario: nil)
@config = config
@destinatario = destinatario.presence || config.email_admin.presence
@usuario = usuario
end
def executar
if @destinatario.blank?
return Resultado.falha('Preencha o "E-mail do administrador" antes de enviar o teste.')
end
unless @config.smtp_pronto?
return Resultado.falha('Marque "Ativar envio de e-mail" e preencha servidor, porta, ' \
'usuário e senha antes de testar.')
end
entrega = TesteMailer.teste(@destinatario, @config, @usuario)
# development.rb define raise_delivery_errors = false e o mail() copia isso
# para a mensagem — sem estas duas linhas o teste "passaria" em silêncio.
entrega.message.raise_delivery_errors = true
entrega.message.perform_deliveries = true
# NUNCA deliver_later: o adapter é :async (thread in-process) e a exceção
# nunca voltaria para o flash.
entrega.deliver_now
Resultado.sucesso("E-mail de teste enviado para #{@destinatario}. " \
'Confira a caixa de entrada e também o spam.')
rescue Net::SMTPAuthenticationError => e
Resultado.falha('Usuário ou senha do SMTP recusados pelo servidor. No Gmail/Google ' \
'Workspace é preciso usar uma "Senha de app" de 16 caracteres, ' \
'não a senha da conta.', e.message)
rescue Net::SMTPSyntaxError, Net::SMTPFatalError => e
Resultado.falha("O servidor recusou a mensagem. Normalmente o remetente " \
"(#{@config.remetente_email.presence || @config.smtp_username}) precisa ser " \
'o mesmo usuário que autenticou.', e.message)
rescue Net::SMTPServerBusy => e
Resultado.falha('Servidor SMTP ocupado ou aplicando limite de envio. ' \
'Tente de novo em alguns minutos.', e.message)
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT => e
Resultado.falha("Tempo esgotado ao conectar em #{@config.smtp_address}:#{@config.smtp_port}. " \
'A porta pode estar bloqueada pelo provedor ou pelo firewall — ' \
'tente 587 (STARTTLS) ou 465 (SSL).', e.message)
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH => e
Resultado.falha("Conexão recusada por #{@config.smtp_address}:#{@config.smtp_port}. " \
'Servidor ou porta incorretos.', e.message)
rescue SocketError => e
Resultado.falha("Não foi possível resolver o endereço \"#{@config.smtp_address}\". " \
'Confira a grafia do servidor.', e.message)
rescue OpenSSL::SSL::SSLError => e
Resultado.falha('Erro de TLS/SSL. A porta 465 exige SSL direto e a 587 exige STARTTLS — ' \
'confira se a porta escolhida bate com o servidor.', e.message)
rescue Net::SMTPError, EOFError, IOError => e
# Net::SMTPError é um módulo incluído nas classes acima; por isso vem
# depois delas, como rede de segurança.
Resultado.falha("O servidor SMTP interrompeu a conexão: #{e.message}", e.message)
rescue StandardError => e
Rails.logger.error("[TesteEmail] #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
Resultado.falha("Falha inesperada ao enviar (#{e.class}).", e.message)
end
end
end

View File

@@ -0,0 +1,74 @@
# app/services/notificacao/teste_whatsapp.rb
#
# Dispara um WhatsApp de teste pelo Twilio para o número administrativo e
# traduz o código de erro da API para uma instrução acionável em português —
# os erros de Sandbox (opt-in) são de longe os mais comuns e o código cru
# ("63003") não diz nada para quem está configurando.
module Notificacao
class TesteWhatsapp
MENSAGENS = {
20003 => 'Account SID ou Auth Token inválidos: o Twilio recusou a autenticação.',
20404 => 'Account SID não encontrado no Twilio. Confira se copiou o SID certo (começa com "AC").',
21211 => 'Número de destino inválido. Informe com DDD, ex.: 11 92005-1157.',
21606 => 'O número remetente não é um número Twilio habilitado para envio.',
21608 => 'Conta trial do Twilio: só é possível enviar para números verificados no console da Twilio.',
21610 => 'Este número pediu descadastro (enviou STOP). Ele precisa enviar START para voltar a receber.',
63003 => 'O Twilio não encontrou o destinatário nesse canal. No Sandbox do WhatsApp o número ' \
'precisa enviar "join <sua-palavra>" para o número do sandbox ANTES de conseguir receber.',
63007 => 'O número remetente não está habilitado como canal de WhatsApp. Use exatamente o ' \
'número do Sandbox (ou do Sender aprovado) do Twilio.',
63015 => 'No Sandbox só dá para enviar a números que fizeram o opt-in ("join <sua-palavra>").',
63016 => 'Passou da janela de 24h desde a última mensagem do destinatário — nesse caso o Twilio ' \
'só aceita template aprovado. Peça para ele mandar qualquer mensagem e tente de novo.',
63018 => 'Limite de mensagens do Twilio atingido. Aguarde e tente novamente.',
63038 => 'Limite diário de mensagens da conta Twilio atingido.'
}.freeze
def initialize(config = ConfiguracaoNotificacao.instancia, destino: nil)
@config = config
@destino = ConfiguracaoNotificacao.canal(destino.presence || config.whatsapp_admin)
end
def executar
if @destino.blank?
return Resultado.falha('Preencha o "WhatsApp do administrador" com DDD ' \
'(ex.: 11 92005-1157) antes de testar.')
end
credenciais = @config.credenciais_whatsapp
if credenciais.blank?
return Resultado.falha('Marque "Ativar WhatsApp" e preencha Account SID, Auth Token e o ' \
'número remetente do Twilio.')
end
if credenciais[:from].blank?
return Resultado.falha('O número remetente do Twilio está vazio ou em formato inválido.')
end
msg = ClienteTwilio
.montar(credenciais[:sid], credenciais[:token])
.messages.create(from: credenciais[:from], to: @destino, body: corpo)
Resultado.sucesso("WhatsApp de teste aceito pelo Twilio para " \
"#{@destino.sub('whatsapp:', '')} (SID #{msg.sid}, status \"#{msg.status}\"). " \
'Atenção: "queued"/"accepted" significa que a Twilio recebeu — ' \
'a entrega final ainda pode falhar depois.')
rescue Twilio::REST::RestError => e
Resultado.falha(MENSAGENS[e.code] || "O Twilio recusou o envio (código #{e.code}).",
"[#{e.code}] #{e.message}")
rescue Twilio::REST::TwilioError => e
Resultado.falha('Não foi possível falar com a API do Twilio (rede ou timeout). ' \
'Verifique a saída de internet do servidor.', e.message)
rescue StandardError => e
Rails.logger.error("[TesteWhatsapp] #{e.class}: #{e.message}")
Resultado.falha("Falha inesperada ao enviar (#{e.class}).", e.message)
end
private
def corpo
"✅ Teste do Reem Notas\n" \
"Se você recebeu esta mensagem, a integração com o Twilio está funcionando.\n" \
"Enviado em #{Time.current.strftime('%d/%m/%Y às %H:%M')}."
end
end
end

View File

@@ -1,10 +1,27 @@
# app/services/notificacao_service.rb
#
# Envia notificações aos motoristas quando uma consolidação é finalizada.
# Canais controlados pelas configurações: notificacao_whatsapp / notificacao_email.
# Falhas de envio NUNCA quebram o fluxo de finalização (apenas logam).
# Envia notificações aos motoristas quando uma consolidação é finalizada ou
# quando o pagamento é efetuado.
#
# Canais e credenciais vêm de ConfiguracaoNotificacao (tela /admin/
# configuracao_notificacao), com fallback para o .env.
#
# Falhas de envio NUNCA quebram o fluxo de finalização — apenas logam. Isso é
# intencional: um SMTP fora do ar não pode impedir o fechamento de um pagamento.
# Para DIAGNOSTICAR o envio, use os botões de teste da tela, que usam
# Notificacao::TesteEmail / Notificacao::TesteWhatsapp e mostram o erro real.
class NotificacaoService
EVENTOS = {
fechado: { mailer: :pagamento_fechado,
emoji: '🚚',
titulo: 'Seu pagamento de entregas foi fechado.',
cta: 'Acesse seu painel para baixar o extrato:' },
pago: { mailer: :pagamento_efetuado,
emoji: '✅',
titulo: 'Seu pagamento foi efetuado.',
cta: 'Acesse seu painel para conferir:' }
}.freeze
def self.notificar_finalizacao(consolidacao)
new(consolidacao).notificar_todos
end
@@ -16,106 +33,73 @@ class NotificacaoService
def initialize(consolidacao)
@consolidacao = consolidacao
@config = ConfiguracaoNotificacao.instancia
end
def notificar_todos
@consolidacao.consolidacao_motoristas.ativos.each do |cm|
user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
next unless user
enviar_whatsapp(user, cm) if whatsapp_ativo?
enviar_email(user, cm) if email_ativo? && user.email.present?
end
@consolidacao.consolidacao_motoristas.ativos.each { |cm| notificar(cm, :fechado) }
end
def enviar_aviso_pagamento(cm)
user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
return unless user
enviar_whatsapp_pagamento(user, cm) if whatsapp_ativo?
enviar_email_pagamento(user, cm) if email_ativo? && user.email.present?
def enviar_aviso_pagamento(consolidacao_motorista)
notificar(consolidacao_motorista, :pago)
end
private
def whatsapp_ativo?
Configuracao.valor('notificacao_whatsapp') == 'true' &&
ENV['TWILIO_ACCOUNT_SID'].present?
def notificar(cm, evento)
user = motorista_de(cm)
return unless user
enviar_whatsapp(user, cm, evento) if @config.whatsapp_habilitado?
enviar_email(user, cm, evento) if @config.email_habilitado? && user.email.present?
end
def email_ativo?
Configuracao.valor('notificacao_email') == 'true'
def motorista_de(cm)
User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
end
def enviar_whatsapp(user, cm, evento)
# O telefone no cadastro pode estar como "(11) 92005-1157"; mandar esse
# texto cru para o Twilio devolve erro 21211 (destino inválido), que o
# rescue abaixo esconderia. Normalizar para E.164 é o que faz o envio
# realmente funcionar.
destino = ConfiguracaoNotificacao.canal(user.telefone)
return if destino.blank?
credenciais = @config.credenciais_whatsapp
return if credenciais.blank? || credenciais[:from].blank?
Notificacao::ClienteTwilio
.montar(credenciais[:sid], credenciais[:token])
.messages.create(from: credenciais[:from], to: destino, body: mensagem(user, cm, evento))
Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}")
rescue StandardError => e
Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{e.class}: #{e.message}")
end
def enviar_email(user, cm, evento)
ConsolidacaoMailer
.public_send(EVENTOS.fetch(evento)[:mailer], user, @consolidacao, cm)
.deliver_later
Rails.logger.info("[Notificacao] Email (#{evento}) agendado para #{user.email}")
rescue StandardError => e
Rails.logger.error("[Notificacao] Falha email (#{evento}) #{user.email}: #{e.class}: #{e.message}")
end
def mensagem(user, cm, evento)
evt = EVENTOS.fetch(evento)
"Olá #{user.nome.split.first}! #{evt[:emoji]}\n" \
"#{evt[:titulo]}\n" \
"📋 #{@consolidacao.nome}\n" \
"💰 Valor: #{moeda(cm.valor_total)}\n" \
"#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
end
# Moeda BR com separador de milhar: R$ 1.234,56
def moeda(v)
inteiro, decimais = format('%.2f', v.to_f).split('.')
def moeda(valor)
inteiro, decimais = format('%.2f', valor.to_f).split('.')
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
"R$ #{inteiro},#{decimais}"
end
def mensagem(user, cm)
valor = moeda(cm.valor_total)
"Olá #{user.nome.split.first}! 🚚\n" \
"Seu pagamento de entregas foi fechado.\n" \
"📋 #{@consolidacao.nome}\n" \
"💰 Valor: #{valor}\n" \
"Acesse seu painel para baixar o extrato: " \
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
end
def enviar_whatsapp(user, cm)
return if user.telefone.blank?
require 'twilio-ruby'
cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN'])
cliente.messages.create(
from: ENV['TWILIO_WHATSAPP_FROM'],
to: "whatsapp:#{user.telefone}",
body: mensagem(user, cm)
)
Rails.logger.info("[Notificacao] WhatsApp enviado para #{user.nome}")
rescue => e
Rails.logger.error("[Notificacao] Falha WhatsApp #{user.nome}: #{e.message}")
end
def enviar_email(user, cm)
ConsolidacaoMailer.pagamento_fechado(user, @consolidacao, cm).deliver_later
Rails.logger.info("[Notificacao] Email agendado para #{user.email}")
rescue => e
Rails.logger.error("[Notificacao] Falha email #{user.email}: #{e.message}")
end
# ── Aviso de pagamento efetuado ─────────────────────────────
def mensagem_pagamento(user, cm)
valor = moeda(cm.valor_total)
"Olá #{user.nome.split.first}! ✅\n" \
"Seu pagamento foi efetuado.\n" \
"📋 #{@consolidacao.nome}\n" \
"💰 Valor: #{valor}\n" \
"Acesse seu painel para conferir: " \
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
end
def enviar_whatsapp_pagamento(user, cm)
return if user.telefone.blank?
require 'twilio-ruby'
cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN'])
cliente.messages.create(
from: ENV['TWILIO_WHATSAPP_FROM'],
to: "whatsapp:#{user.telefone}",
body: mensagem_pagamento(user, cm)
)
Rails.logger.info("[Notificacao] WhatsApp pagamento enviado para #{user.nome}")
rescue => e
Rails.logger.error("[Notificacao] Falha WhatsApp pagamento #{user.nome}: #{e.message}")
end
def enviar_email_pagamento(user, cm)
ConsolidacaoMailer.pagamento_efetuado(user, @consolidacao, cm).deliver_later
Rails.logger.info("[Notificacao] Email pagamento agendado para #{user.email}")
rescue => e
Rails.logger.error("[Notificacao] Falha email pagamento #{user.email}: #{e.message}")
end
end

View File

@@ -41,14 +41,38 @@ module SimpliRoute
end
# Todas as visitas de uma data (Array de hashes).
def visitas_da_data(data)
Array(get("/v1/routes/visits/?planned_date=#{data.to_date.iso8601}"))
#
# `busca` vira `&search=` — parâmetro NÃO documentado, mas verificado contra
# a API real em 21/07/2026: filtra de verdade e derruba a resposta de um dia
# de ~3,8 MB / ~9 s (1879 visitas) para ~1 KB / ~0,6 s. Sem ele, varrer um
# mês é inviável.
#
# ⚠️ NÃO confiar só nele: `reference`, `reference_id`, `q` e `title` são
# ignorados em SILÊNCIO pela API (devolvem 200 com o dia inteiro). Se o
# `search` mudar de nome, cai no mesmo comportamento — por isso quem chama
# continua filtrando por `reference`, e a perda é só de desempenho.
#
# ⚠️ SEM `planned_date` a API responde um conjunto padrão (~2469 visitas,
# medido) que NÃO cobre o histórico: a NF 82891 voltava só com a visita de
# 21/07, escondendo a de 17/07. Todo filtro de intervalo testado
# (planned_date_from/to, since/until, __gte/__lte, date_from/to) é ignorado.
# Por isso a data continua obrigatória e a varredura é dia a dia.
def visitas_da_data(data, busca: nil)
caminho = "/v1/routes/visits/?planned_date=#{data.to_date.iso8601}"
caminho += "&search=#{URI.encode_www_form_component(busca.to_s)}" if busca.present?
Array(get(caminho))
end
# Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega
# do espelho local, que só tem tracking_id (SR...) + reference_id (NF) +
# planned_date. Casa pelo tracking_id; se não achar, tenta pela NF.
# Levanta NotFound (nada) ou Error (ambiguidade sem tracking_id).
#
# ⚠️ Ambiguidade por NF é REAL (plano duplicado gera duas visitas com a mesma
# NF). Quem precisa lidar com isso — a tela de edição — usa
# `visitas_da_data` + `casa_entrega?` e deixa o ADM escolher; este método
# continua servindo os fluxos que exigem resposta única.
def resolver_id(entrega)
data = entrega.planned_date&.to_date
raise NotFound, 'Entrega sem planned_date — impossível localizar na API.' if data.blank?

View File

@@ -0,0 +1,262 @@
<%# app/views/admin/configuracao_notificacoes/show.html.erb %>
<%
input = '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'
rotulo = 'block text-sm font-medium text-gray-300 mb-1.5'
card = 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6'
toggle = "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]"
ajuda = 'text-gray-500 text-xs mt-1.5'
botao_2 = 'px-6 py-3 border border-white/10 hover:border-white/20 text-gray-300
hover:text-white rounded-xl transition-colors cursor-pointer min-h-[48px]'
%>
<div class="max-w-3xl mx-auto space-y-6">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
<%= icone :email, espaco: false %> Notificações e E-mail
</h1>
<p class="text-gray-400 text-sm mt-0.5">
Servidor de e-mail (SMTP) e WhatsApp (Twilio) usados pelo sistema
</p>
</div>
<%= link_to 'Voltar', admin_configuracoes_path,
class: 'px-4 py-2 text-gray-400 hover:text-white border border-white/10
hover:border-white/20 rounded-xl transition-colors text-sm whitespace-nowrap' %>
</div>
<%= render 'shared/flash' %>
<%# Status atual — o que o sistema REALMENTE vai usar agora %>
<div class="flex flex-wrap gap-3">
<div class="flex items-center gap-2 px-4 py-2 bg-[#1a1a1a] border border-white/5 rounded-xl text-sm">
<span class="w-2 h-2 rounded-full <%= @config.smtp_pronto? ? 'bg-green-400' : 'bg-gray-600' %>"></span>
<span class="text-gray-400">E-mail:</span>
<span class="<%= @config.smtp_pronto? ? 'text-green-400' : 'text-gray-500' %> font-medium">
<%= @config.smtp_pronto? ? 'configurado' : 'usando .env / desligado' %>
</span>
</div>
<div class="flex items-center gap-2 px-4 py-2 bg-[#1a1a1a] border border-white/5 rounded-xl text-sm">
<span class="w-2 h-2 rounded-full <%= @config.whatsapp_pronto? ? 'bg-green-400' : 'bg-gray-600' %>"></span>
<span class="text-gray-400">WhatsApp:</span>
<span class="<%= @config.whatsapp_pronto? ? 'text-green-400' : 'text-gray-500' %> font-medium">
<%= @config.whatsapp_pronto? ? 'configurado' : 'usando .env / desligado' %>
</span>
</div>
</div>
<%# Segredo gravado que não pôde ser decifrado (SECRET_KEY_BASE rotacionado) %>
<% ilegiveis = [] %>
<% ilegiveis << 'senha do SMTP' if @config.smtp_password_cifrado.present? && @config.smtp_password.blank? %>
<% ilegiveis << 'token do Twilio' if @config.twilio_auth_token_cifrado.present? && @config.twilio_auth_token.blank? %>
<% if ilegiveis.any? %>
<div class="p-4 bg-yellow-900/30 border border-yellow-500/40 rounded-xl text-yellow-300 text-sm">
Não foi possível ler <%= ilegiveis.to_sentence %> que estava gravada no banco
(o <code class="font-mono">SECRET_KEY_BASE</code> do servidor mudou).
Digite novamente e salve. Enquanto isso o sistema usa o que está no arquivo <code class="font-mono">.env</code>.
</div>
<% end %>
<%= form_with(model: @config, url: admin_configuracao_notificacao_path, method: :patch,
class: 'space-y-6') do |f| %>
<% if @config.errors.any? %>
<div class="p-4 bg-red-900/30 border border-red-500/40 rounded-xl">
<p class="text-red-400 text-sm font-medium mb-2">
<%= pluralize(@config.errors.count, 'erro', 'erros') %> encontrado<%= @config.errors.count > 1 ? 's' : '' %>:
</p>
<ul class="list-disc list-inside space-y-1">
<% @config.errors.full_messages.each do |msg| %>
<li class="text-red-300 text-sm"><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<%# ─────────────────────────── E-MAIL / SMTP ─────────────────────────── %>
<div class="<%= card %>">
<div class="flex items-center justify-between">
<div class="flex-1">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<%= icone :email, espaco: false %> Servidor de e-mail (SMTP)
</h2>
<p class="text-gray-500 text-xs mt-0.5">
Usado no envio de extratos, avisos de pagamento e recuperação de senha
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer ml-4">
<%= f.check_box :smtp_ativo, class: 'sr-only peer' %>
<div class="<%= toggle %>"></div>
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<%= f.label :smtp_address, 'Servidor', class: rotulo %>
<%= f.text_field :smtp_address, class: input, placeholder: 'smtp.gmail.com' %>
</div>
<div>
<%= f.label :smtp_port, 'Porta', class: rotulo %>
<%= f.number_field :smtp_port, min: 1, max: 65535, class: input, placeholder: '587' %>
<p class="<%= ajuda %>">587 para STARTTLS (padrão) ou 465 para SSL direto.</p>
</div>
<div>
<%= f.label :smtp_username, 'Usuário', class: rotulo %>
<%= f.text_field :smtp_username, autocomplete: 'off', class: input,
placeholder: 'sistema@suaempresa.com.br' %>
</div>
<div>
<%= f.label :smtp_password, 'Senha', class: rotulo %>
<%# value: nil — a senha gravada NUNCA volta para o HTML. %>
<%= f.password_field :smtp_password, value: nil, autocomplete: 'new-password', class: input,
placeholder: @config.smtp_password? ? '•••••••• (em branco mantém a atual)' : 'senha ou senha de app' %>
<p class="<%= ajuda %>">
No Gmail/Google Workspace use uma <strong>Senha de app</strong> de 16 caracteres,
não a senha da conta.
</p>
</div>
<div>
<%= f.label :smtp_autenticacao, 'Autenticação', class: rotulo %>
<%= f.select :smtp_autenticacao,
options_for_select(ConfiguracaoNotificacao::AUTENTICACOES, @config.smtp_autenticacao),
{}, class: "#{input} cursor-pointer" %>
</div>
<div>
<%= f.label :smtp_domain, 'Domínio (opcional)', class: rotulo %>
<%= f.text_field :smtp_domain, class: input, placeholder: 'suaempresa.com.br' %>
</div>
<div>
<%= f.label :remetente_email, 'E-mail remetente', class: rotulo %>
<%= f.email_field :remetente_email, class: input, placeholder: 'nao-responda@suaempresa.com.br' %>
<p class="<%= ajuda %>">Em branco, usa o próprio usuário do SMTP.</p>
</div>
<div>
<%= f.label :remetente_nome, 'Nome do remetente', class: rotulo %>
<%= f.text_field :remetente_nome, class: input, placeholder: 'Reem Transporte' %>
</div>
</div>
<div class="flex items-center border-t border-white/5 pt-6">
<div class="flex-1">
<%= f.label :email_notificacoes_ativo, 'Avisar motoristas por e-mail', class: 'block text-sm font-medium text-gray-300' %>
<p class="text-gray-500 text-xs mt-0.5">
Envia o aviso de pagamento fechado e de pagamento efetuado para o motorista
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer ml-4">
<%= f.check_box :email_notificacoes_ativo, class: 'sr-only peer' %>
<div class="<%= toggle %>"></div>
</label>
</div>
</div>
<%# ────────────────────────── WHATSAPP / TWILIO ───────────────────────── %>
<div class="<%= card %>">
<div class="flex items-center justify-between">
<div class="flex-1">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<%= icone :telefone, espaco: false %> WhatsApp (Twilio)
</h2>
<p class="text-gray-500 text-xs mt-0.5">
Credenciais em Twilio Console → Account Info
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer ml-4">
<%= f.check_box :whatsapp_ativo, class: 'sr-only peer' %>
<div class="<%= toggle %>"></div>
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<%= f.label :twilio_account_sid, 'Account SID', class: rotulo %>
<%= f.text_field :twilio_account_sid, autocomplete: 'off', class: "#{input} font-mono text-sm",
placeholder: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' %>
</div>
<div>
<%= f.label :twilio_auth_token, 'Auth Token', class: rotulo %>
<%= f.password_field :twilio_auth_token, value: nil, autocomplete: 'new-password',
class: "#{input} font-mono text-sm",
placeholder: @config.twilio_auth_token? ? '•••••••• (em branco mantém o atual)' : 'token da conta Twilio' %>
</div>
<div class="sm:col-span-2">
<%= f.label :twilio_from, 'Número remetente (o que envia)', class: rotulo %>
<%= f.text_field :twilio_from, class: input, placeholder: '+1 415 523 8886 (sandbox) ou seu número aprovado' %>
<p class="<%= ajuda %>">
Precisa ser o número do <strong>Sandbox</strong> ou um Sender aprovado no Twilio —
não dá para usar um número de WhatsApp comum.
</p>
</div>
</div>
<div class="p-4 bg-[#0a0a0a] border border-white/5 rounded-xl">
<p class="text-gray-400 text-xs leading-relaxed">
<strong class="text-gray-300">Sandbox do Twilio:</strong> antes do primeiro envio, o
número que vai <em>receber</em> precisa mandar <code class="font-mono text-[#f97316]">join &lt;sua-palavra&gt;</code>
no WhatsApp para o número do sandbox. Sem esse opt-in o Twilio recusa com o erro 63003/63015.
</p>
</div>
</div>
<%# ───────────────────── DESTINATÁRIOS ADMINISTRATIVOS ────────────────── %>
<div class="<%= card %>">
<div>
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<%= icone :pessoa, espaco: false %> Destinatários administrativos
</h2>
<p class="text-gray-500 text-xs mt-0.5">
Para onde vão os testes abaixo e os avisos destinados à administração
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<%= f.label :email_admin, 'E-mail do administrador', class: rotulo %>
<%= f.email_field :email_admin, class: input, placeholder: 'voce@suaempresa.com.br' %>
</div>
<div>
<%= f.label :whatsapp_admin, 'WhatsApp do administrador', class: rotulo %>
<%= f.text_field :whatsapp_admin, class: input, placeholder: '11 92005-1157' %>
<p class="<%= ajuda %>">Com DDD. É gravado no formato internacional (+55…).</p>
</div>
</div>
</div>
<%# ──────────────────────────────── AÇÕES ────────────────────────────── %>
<%# Os três botões submetem ESTE form; o controller decide pelo name="acao".
Assim o teste roda sobre o que está na tela, já salvo. %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-4">
<div class="flex flex-wrap items-center gap-3">
<%= f.button 'Salvar', type: :submit, name: 'acao', value: 'salvar',
class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold
rounded-xl transition-colors cursor-pointer min-h-[48px]' %>
<%= f.button 'Salvar e enviar e-mail de teste', type: :submit,
name: 'acao', value: 'testar_email', class: botao_2 %>
<%= f.button 'Salvar e enviar WhatsApp de teste', type: :submit,
name: 'acao', value: 'testar_whatsapp', class: botao_2 %>
</div>
<p class="text-gray-500 text-xs">
Os testes salvam as alterações antes de enviar e mostram o erro exato retornado
pelo servidor de e-mail ou pelo Twilio.
</p>
</div>
<% end %>
</div>

View File

@@ -8,10 +8,33 @@
<%= render 'shared/flash' %>
<%# Configurações sensíveis (credenciais de envio) — só ADM %>
<% if current_user.admin? %>
<div>
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :travado, espaco: false %> Configurações do Administrador</h2>
<div class="grid grid-cols-[repeat(auto-fit,minmax(18rem,1fr))] gap-4">
<%= link_to admin_configuracao_notificacao_path,
class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 hover:border-[#f97316]/50
p-6 flex items-start gap-4 transition-colors group' do %>
<div class="text-3xl">✉️</div>
<div class="flex-1 min-w-0">
<p class="text-white font-semibold group-hover:text-[#f97316] transition-colors">Notificações e E-mail</p>
<p class="text-gray-500 text-xs mt-1 leading-relaxed">
Servidor de e-mail (SMTP), WhatsApp via Twilio e os contatos que recebem os avisos da administração
</p>
</div>
<% end %>
</div>
</div>
<% end %>
<%# Pilares de preço %>
<div>
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Pilares de Preço</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<%# auto-fit: `lg:grid-cols-4` mede a JANELA, mas a sidebar come 256px do
conteúdo — os cards de preço ficavam com ~128px úteis e o valor em
moeda espremia. Aqui o número de colunas segue a largura REAL. %>
<div class="grid grid-cols-[repeat(auto-fit,minmax(14rem,1fr))] gap-4">
<% @configuracoes.each do |cfg| %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 group relative">
<div class="flex items-start justify-between mb-4">

View File

@@ -4,6 +4,7 @@
/history/ da API). Os dados do card chegam via fetch (endpoint buscar). %>
<div class="space-y-6" id="edicao-app"
data-buscar-url="<%= buscar_admin_edicao_lancamento_path %>"
data-carregar-url="<%= carregar_admin_edicao_lancamento_path %>"
data-atualizar-url="<%= atualizar_admin_edicao_lancamento_path %>"
data-historico-url="<%= historico_admin_edicao_lancamento_path %>"
data-csrf="<%= form_authenticity_token %>"
@@ -39,8 +40,50 @@
Buscar
</button>
</div>
<%# Período — a API do SimpliRoute só filtra visita por UM dia, então achar
uma NF exige saber em quais dias procurar. Sem período, procuramos nas
datas que o painel já conhece; quando o plano foi duplicado para outra
data, o painel não sabe dela e é preciso ampliar aqui. %>
<div class="flex flex-wrap items-end gap-2 mt-3">
<div>
<label class="block text-xs text-gray-500 mb-1" for="campo-de">De</label>
<input id="campo-de" type="date"
class="bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2 text-white text-sm
focus:outline-none focus:border-orange-500 min-h-[42px]">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1" for="campo-ate">Até</label>
<input id="campo-ate" type="date"
class="bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2 text-white text-sm
focus:outline-none focus:border-orange-500 min-h-[42px]">
</div>
<button id="btn-30dias" type="button"
class="px-4 py-2 bg-[#1a1a1a] hover:bg-[#252525] border border-white/10 text-gray-200
text-sm rounded-xl transition-colors min-h-[42px] whitespace-nowrap">
Últimos 30 dias
</button>
<button id="btn-limpar-periodo" type="button"
class="px-3 py-2 text-gray-500 hover:text-gray-300 text-sm min-h-[42px]">Limpar</button>
</div>
<p class="mt-2 text-xs text-gray-500">
Sem período, procuramos só nas datas que o painel conhece. Se o plano foi duplicado para outra
data, amplie o período — a API do SimpliRoute não busca NF por intervalo, então varremos dia a
dia (máx. 62 dias).
</p>
<p id="busca-erro" class="hidden mt-2 text-sm text-red-400"></p>
<p id="busca-loading" class="hidden mt-2 text-sm text-gray-400">Consultando SimpliRoute…</p>
<p id="busca-periodo" class="hidden mt-2 text-xs text-gray-500"></p>
<div id="busca-falhas" class="hidden mt-2 p-3 rounded-xl bg-red-900/20 border border-red-500/40 text-xs text-red-200 space-y-1"></div>
</div>
<%# Lançamentos da NF — só aparece quando há mais de um. Plano duplicado no
SimpliRoute cria uma visita nova com a MESMA NF, e a antiga (a que tem o
motorista) continua valendo: aqui o ADM vê as duas e escolhe qual editar. %>
<div id="ocorrencias" class="hidden bg-[#111] border border-white/5 rounded-2xl p-5 space-y-3">
<p class="text-sm text-gray-300">
<span id="oc-contador" class="font-semibold text-white"></span> <span id="oc-dica"></span>
</p>
<div id="oc-lista" class="space-y-2"></div>
</div>
<%# Card do lançamento (preenchido via JS após a busca) %>
@@ -142,6 +185,7 @@
const app = document.getElementById('edicao-app');
const URLS = {
buscar: app.dataset.buscarUrl,
carregar: app.dataset.carregarUrl,
atualizar: app.dataset.atualizarUrl,
historico: app.dataset.historicoUrl
};
@@ -149,6 +193,8 @@
const MOTIVOS = JSON.parse(app.dataset.motivos || '[]');
let visita = null; // estado atual do lançamento carregado
let ocorrencias = []; // todos os lançamentos da NF buscada
let selecionado = null; // tracking_id do lançamento aberto no card
// ── Config dos campos editáveis ─────────────────────────────
// Ícone do sprite (public/icons.svg). fill=currentColor -> herda a cor do badge.
@@ -185,31 +231,191 @@
const loadEl = document.getElementById('busca-loading');
const cardEl = document.getElementById('card');
const campoDe = document.getElementById('campo-de');
const campoAte = document.getElementById('campo-ate');
const periodoEl = document.getElementById('busca-periodo');
const falhasEl = document.getElementById('busca-falhas');
async function buscar() {
const nf = campoNf.value.trim();
erroEl.classList.add('hidden');
periodoEl.classList.add('hidden');
falhasEl.classList.add('hidden');
if (!nf) { mostrarErroBusca('Informe o número da NF.'); return; }
loadEl.classList.remove('hidden');
cardEl.classList.add('hidden');
ocorrencias = [];
selecionado = null;
try {
const resp = await fetch(URLS.buscar + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } });
let url = URLS.buscar + '?nf=' + encodeURIComponent(nf);
if (campoDe.value) url += '&de=' + encodeURIComponent(campoDe.value);
if (campoAte.value) url += '&ate=' + encodeURIComponent(campoAte.value);
const resp = await fetch(url, { headers: { 'Accept': 'application/json' } });
const data = await resp.json();
if (!data.ok) { mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; }
visita = data.visita;
renderCard();
cardEl.classList.remove('hidden');
if (!data.ok) { renderOcorrencias(); mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; }
ocorrencias = data.ocorrencias || [];
renderPeriodo(data.periodo, data.falhas);
// Só veio o card pronto quando a NF tem um lançamento único; com mais de
// um, o ADM escolhe na lista antes de abrir qualquer coisa.
if (data.visita) {
abrirCard(data.visita);
} else {
visita = null;
renderOcorrencias();
// Tela em branco é pior que erro: se não há card E a lista ficou
// escondida, o ADM precisa saber por quê em vez de achar que a NF
// não existe.
if (ocEl.classList.contains('hidden')) {
mostrarErroBusca(ocorrencias.length
? 'O lançamento foi localizado mas não pôde ser aberto — veja os avisos acima.'
: 'Nenhum lançamento localizado para esta NF no período consultado.');
}
}
} catch (e) {
mostrarErroBusca('Erro de conexão ao buscar a NF.');
// Mostra o erro real: "Erro de conexão" genérico escondeu uma exceção de
// renderização e deixou a tela muda.
console.error('[edicao-lancamento] falha na busca', e);
mostrarErroBusca('Falha ao processar a busca: ' + ((e && e.message) || e));
} finally {
loadEl.classList.add('hidden');
}
}
function mostrarErroBusca(msg) { erroEl.textContent = msg; erroEl.classList.remove('hidden'); }
// Dizer o que foi consultado e o que falhou. Uma busca que devolve menos do
// que existe precisa admitir isso — senão parece que a NF não existe.
function renderPeriodo(periodo, falhas) {
if (periodo && periodo.dias) {
periodoEl.textContent = 'Consultado: ' + fmtData(periodo.de) +
(periodo.dias > 1 ? ' a ' + fmtData(periodo.ate) + ' (' + periodo.dias + ' dias)' : '') + '.';
periodoEl.classList.remove('hidden');
}
if (falhas && falhas.length) {
falhasEl.innerHTML = '<p class="font-semibold">A API do SimpliRoute falhou em ' + falhas.length +
' dia(s) — pode haver lançamento faltando na lista:</p>' +
falhas.map(f => '<p>' + esc(f.data) + ' — ' + esc(f.erro) + '</p>').join('');
falhasEl.classList.remove('hidden');
}
}
document.getElementById('btn-buscar').addEventListener('click', buscar);
campoNf.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
document.getElementById('btn-30dias').addEventListener('click', () => {
const hoje = new Date();
const trinta = new Date(hoje.getTime() - 30 * 86400000);
const iso = d => d.toISOString().slice(0, 10);
campoDe.value = iso(trinta);
campoAte.value = iso(hoje);
if (campoNf.value.trim()) buscar();
});
document.getElementById('btn-limpar-periodo').addEventListener('click', () => {
campoDe.value = ''; campoAte.value = '';
});
// ── Lista de lançamentos da NF ──────────────────────────────
const ocEl = document.getElementById('ocorrencias');
const ocLista = document.getElementById('oc-lista');
const ocCont = document.getElementById('oc-contador');
const ocDica = document.getElementById('oc-dica');
// ⚠️ NÃO usar new Date('2026-07-15') para data pura: o JS lê como meia-noite
// UTC e o toLocale em UTC-3 devolve o DIA ANTERIOR. Data planejada não é um
// instante — formata direto do texto ISO.
const fmtData = v => {
if (!v) return '—';
const s = String(v);
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (iso) return iso[3] + '/' + iso[2] + '/' + iso[1];
const d = new Date(s);
return isNaN(d) ? s.slice(0, 10) : d.toLocaleDateString('pt-BR');
};
const fmtHora = v => {
if (!v) return null;
const d = new Date(v);
return isNaN(d) ? null : d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
};
// Com um lançamento editável só, a lista some — a tela fica igual ao que era
// antes. Fica visível se ele não for editável, para explicar o porquê.
function renderOcorrencias() {
const n = ocorrencias.length;
if (n === 0 || (n === 1 && ocorrencias[0].editavel)) {
ocEl.classList.add('hidden'); ocLista.innerHTML = ''; return;
}
// O bloco nasce com `hidden` no ERB — sem tirar a classe aqui, a lista é
// montada e fica invisível (a tela parecia não ter achado nada).
ocEl.classList.remove('hidden');
ocCont.textContent = n + (n === 1 ? ' lançamento' : ' lançamentos');
ocDica.textContent = n > 1 ? 'para esta NF — escolha qual quer editar.' : 'para esta NF.';
ocLista.innerHTML = ocorrencias.map((o, i) => {
const cfg = STATUS[o.status] || { label: o.status || '—', cls: 'bg-gray-800 text-gray-400', icone: 'question-circle' };
const ativo = selecionado != null && String(selecionado) === String(o.tracking_id);
return '' +
'<div class="flex flex-wrap items-center justify-between gap-3 p-3 rounded-xl border ' +
(ativo ? 'border-orange-500/60 bg-orange-500/10' : 'border-white/10 bg-[#1a1a1a]') + '">' +
'<div class="min-w-0">' +
'<div class="flex items-center gap-2 flex-wrap">' +
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold ' + cfg.cls + '">' +
ico(cfg.icone) + ' ' + esc(cfg.label) + '</span>' +
'<span class="text-white text-sm font-semibold">' + esc(fmtData(o.data)) + '</span>' +
'<span class="text-xs text-gray-500">Visita ' + esc(o.id ?? '—') + '</span>' +
'</div>' +
'<p class="text-xs text-gray-400 mt-1">' +
// "sem motorista" (o painel sabe e está vazio) é diferente de
// "não identificado" (a visita nem está no painel ainda).
(o.motorista ? esc(o.motorista)
: '<span class="text-gray-600">' + (o.no_painel ? 'sem motorista' : 'motorista não identificado') + '</span>') +
' · ' +
(o.veiculo ? esc(o.veiculo) : '<span class="text-gray-600">' + (o.no_painel ? 'sem veículo' : 'veículo não identificado') + '</span>') +
(fmtHora(o.checkout_time) ? ' · <span class="text-gray-300">checkout ' + esc(fmtHora(o.checkout_time)) + '</span>' : '') +
'</p>' +
(o.aviso ? '<p class="text-xs text-amber-400 mt-1">' + esc(o.aviso) + '</p>' : '') +
'</div>' +
(o.editavel
? '<button type="button" data-oc="' + i + '" class="px-4 py-2 text-sm font-semibold rounded-xl transition-colors ' +
(ativo ? 'bg-white/5 text-gray-400 border border-white/10' : 'bg-[#f97316] hover:bg-orange-500 text-white') + '">' +
(ativo ? 'Editando' : 'Editar') + '</button>'
: '') +
'</div>';
}).join('');
}
ocLista.addEventListener('click', e => {
const btn = e.target.closest('[data-oc]');
if (btn) abrirOcorrencia(ocorrencias[Number(btn.dataset.oc)]);
});
async function abrirOcorrencia(o) {
if (!o || !o.id) return;
erroEl.classList.add('hidden');
loadEl.classList.remove('hidden');
cardEl.classList.add('hidden');
try {
const resp = await fetch(URLS.carregar + '?visit_id=' + encodeURIComponent(o.id) +
(o.tracking_id ? '&tracking_id=' + encodeURIComponent(o.tracking_id) : ''),
{ headers: { 'Accept': 'application/json' } });
const data = await resp.json();
if (!data.ok) { mostrarErroBusca(data.erro || 'Não foi possível abrir este lançamento.'); return; }
abrirCard(data.visita);
cardEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (e) {
mostrarErroBusca('Erro de conexão ao abrir o lançamento.');
} finally {
loadEl.classList.add('hidden');
}
}
function abrirCard(v) {
visita = v;
selecionado = v.tracking_id;
renderCard();
renderOcorrencias(); // destaca na lista qual está aberto
cardEl.classList.remove('hidden');
}
// ── Card ────────────────────────────────────────────────────
function renderCard() {
const v = visita;
@@ -440,7 +646,12 @@
if (data.campos && data.campos.length) Object.assign(visita, data.visita);
pintarExibicao(div, def, data.campos && data.campos.length > 0);
div.classList.add('cursor-pointer');
if (def.campo === 'status') renderBadge();
if (def.campo === 'status') {
renderBadge();
// Mantém a lista coerente com o que acabou de ser gravado.
const o = ocorrencias.find(x => String(x.tracking_id) === String(visita.tracking_id));
if (o) { o.status = visita.status; renderOcorrencias(); }
}
} catch (e) {
msgErro.textContent = 'Erro de conexão ao salvar.';
msgErro.classList.remove('hidden');

View File

@@ -11,7 +11,10 @@
<%# Pilares de preço %>
<div>
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Pilares de Preço</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<%# auto-fit: `lg:grid-cols-4` mede a JANELA, mas a sidebar come 256px do
conteúdo — os cards de preço ficavam com ~128px úteis e o valor em
moeda espremia. Aqui o número de colunas segue a largura REAL. %>
<div class="grid grid-cols-[repeat(auto-fit,minmax(14rem,1fr))] gap-4">
<% @configuracoes.each do |cfg| %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 group relative">
<div class="flex items-start justify-between mb-4">

View File

@@ -11,8 +11,11 @@
<p class="text-gray-400 text-sm"><%= @consolidacao.nome %></p>
</div>
<%# Resumo por tipo %>
<div class="grid grid-cols-2 md:grid-cols-7 gap-3 mb-6">
<%# Resumo por tipo — auto-fit em vez de `md:grid-cols-7`.
Sete colunas fixas a partir de 768px dão ~55px cada quando a sidebar está
aberta (768 256 de menu padding), destruindo rótulos como
"Extraordinária" e "Termo Especial". O mínimo de 9rem cabe o maior rótulo. %>
<div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3 mb-6">
<% [
['entrega_normal', 'Normal', 'bg-orange-500 text-black'],
['retirada', 'Retirada', 'bg-orange-800 text-white'],

View File

@@ -12,38 +12,45 @@
<%# Cabeçalho + controles fixos no topo (no desktop); só a lista de NFs rola. %>
<div class="lg:shrink-0">
<%# Header %>
<div class="mb-3">
<%= link_to '← Voltar (Passo 1)', wizard_consolidacao_path(@consolidacao),
class: 'inline-flex items-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
<div class="flex items-center justify-between mt-2">
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-2"><%= icone :caminhao, espaco: false %> <%= @motorista %></h1>
<p class="text-gray-400 text-sm"><%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %></p>
</div>
<%= link_to safe_join(['Revisar (Passo 3)', icone(:avancar, cor: nil)], ' '),
revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista),
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center' %>
</div>
<%# Cabeçalho compacto: voltar, identificação e progresso numa faixa só.
Espaço vertical aqui é CARO — tudo isto fica fixo e é subtraído da lista,
que é o que o ADM realmente precisa ver. Antes eram 178px em dois cards. %>
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 mb-2">
<%= link_to wizard_consolidacao_path(@consolidacao), title: 'Voltar (Passo 1)',
class: 'shrink-0 inline-flex items-center justify-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-3 h-11 rounded-lg text-sm' do %>
←<span class="hidden sm:inline">Passo 1</span>
<% end %>
<%# basis-40: o nome não pode ser espremido até virar "P…" — se não couber
com o resto, a linha quebra em vez de esmagar a identificação. %>
<div class="min-w-0 flex-1 basis-40">
<h1 class="text-xl lg:text-2xl font-bold text-white flex items-center gap-2 truncate"><%= icone :caminhao, espaco: false %> <%= @motorista %></h1>
<p class="text-gray-400 text-sm truncate"><%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %></p>
</div>
<%# Barra de progresso (reflete o veículo selecionado, se houver) %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
<div class="flex justify-between text-sm mb-2">
<span class="text-white font-semibold">
<% if @veiculos_sel.any? %><%= icone :caminhao %> <%= @veiculos_sel.join(', ') %> — <% end %>
<span data-validacao-target="contadorClassificadas"><%= @classificadas %></span>
de <span data-validacao-target="contadorTotal"><%= @total_entregas %></span> entregas classificadas
<span data-validacao-target="manuaisInfo" class="<%= 'hidden' unless @manuais.positive? %> text-blue-400 font-normal">·
<%# Progresso ao lado do título (em vez de um card de 70px só p/ ele). No
celular vai para uma linha própria, no fim, para não brigar pelo espaço.
Números em branco/negrito: esta é a informação que o ADM confere o tempo
todo enquanto classifica — compactar não pode custar a leitura dela. %>
<p class="order-last w-full text-left sm:order-none sm:w-auto sm:shrink-0 sm:text-right text-sm text-gray-400">
<% if @veiculos_sel.any? %><span class="text-gray-300"><%= icone :caminhao %> <%= @veiculos_sel.join(', ') %></span> — <% end %>
<span class="text-white font-semibold"><span data-validacao-target="contadorClassificadas"><%= @classificadas %></span>/<span data-validacao-target="contadorTotal"><%= @total_entregas %></span></span>
classificadas
<span data-validacao-target="manuaisInfo" class="<%= 'hidden' unless @manuais.positive? %> text-blue-400">·
+<span data-validacao-target="contadorManuais"><%= @manuais %></span> manuais</span>
</span>
<span class="text-orange-500 font-bold" data-validacao-target="percentual"></span>
</div>
<%# Barra com 2 segmentos: laranja/verde = elegíveis classificadas · azul = lançamentos manuais %>
<div class="w-full bg-[#0a0a0a] rounded-full h-3 flex overflow-hidden">
<div data-validacao-target="barra" class="h-3 bg-orange-500 transition-all duration-300" style="width: 0%"></div>
<div data-validacao-target="barraManuais" class="h-3 bg-blue-500 transition-all duration-300" style="width: 0%"></div>
<span class="text-orange-500 font-bold ml-1 text-base" data-validacao-target="percentual"></span>
</p>
<%= link_to revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista),
class: 'shrink-0 bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 h-11 rounded-lg flex items-center gap-1 text-sm' do %>
Revisar<span class="hidden sm:inline"> (Passo 3)</span><%= icone :avancar, cor: nil %>
<% end %>
</div>
<%# Barra com 2 segmentos: laranja = elegíveis classificadas · azul = manuais %>
<div class="w-full bg-[#0a0a0a] rounded-full h-2 flex overflow-hidden mb-2">
<div data-validacao-target="barra" class="h-2 bg-orange-500 transition-all duration-300" style="width: 0%"></div>
<div data-validacao-target="barraManuais" class="h-2 bg-blue-500 transition-all duration-300" style="width: 0%"></div>
</div>
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/
@@ -136,47 +143,61 @@
</div>
<% end %>
<%# Ações em massa + filtro %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3 flex flex-wrap items-center gap-3">
<label class="flex items-center gap-2 text-gray-300 text-sm cursor-pointer select-none">
<%# Barra de ações — uma linha só. O botão "Adicionar lançamento" (bloco
abaixo, que carrega os modais) entra NESTA mesma faixa via flex `order`,
sem precisar mover os modais de lugar. %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl px-3 py-2 mb-2 flex flex-wrap items-center gap-x-3 gap-y-2">
<label class="order-1 flex items-center gap-2 text-gray-300 text-sm cursor-pointer select-none h-11 whitespace-nowrap">
<input type="checkbox" data-validacao-target="selecionarTodos"
data-action="change->validacao#selecionarTodos"
class="rounded text-orange-500 bg-[#0a0a0a] border-[#2a2a2a] w-5 h-5">
Selecionar todos
Selecionar
<%# Contador aparece só quando há seleção — é o retorno visual de que os
pilares saíram do estado inerte. %>
<span data-validacao-target="chipSelecao"
class="hidden px-2 py-0.5 rounded-full bg-orange-500 text-black text-xs font-bold"><span data-validacao-target="contadorSelecao">0</span></span>
</label>
<div class="flex items-center gap-2 flex-wrap border-l border-[#2a2a2a] pl-3">
<span class="text-gray-500 text-sm">Aplicar nos selecionados <span class="text-gray-600">(clique de novo p/ remover)</span>:</span>
<%# Pilares SEMPRE visíveis: são a ação principal da tela e o ADM precisa
vê-los para saber o que pode fazer. Sem seleção eles ficam esmaecidos
(não escondidos) — a versão anterior os removia do DOM e obrigava a
descobrir que existiam depois de marcar algo. %>
<div data-validacao-target="grupoPilares"
class="order-2 flex items-center gap-1.5 flex-wrap opacity-40 transition-opacity"
title="Marque ao menos uma entrega para aplicar em massa">
<button data-action="click->validacao#marcarSelecionados" data-tipo="entrega_normal"
class="bg-orange-500 text-black font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Normal</button>
class="bg-orange-500 text-black font-bold px-3 h-11 rounded-lg text-xs">Normal</button>
<button data-action="click->validacao#marcarSelecionados" data-tipo="retirada"
class="bg-orange-800 text-white font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Retirada</button>
class="bg-orange-800 text-white font-bold px-3 h-11 rounded-lg text-xs">Retirada</button>
<button data-action="click->validacao#marcarSelecionados" data-tipo="bonus"
class="bg-white text-black font-bold px-3 py-2 rounded-lg text-xs border border-orange-500 min-h-[44px]">Bônus</button>
class="bg-white text-black font-bold px-3 h-11 rounded-lg text-xs border border-orange-500">Bônus</button>
<button data-action="click->validacao#marcarSelecionados" data-tipo="desconto"
class="bg-black text-white font-bold px-3 py-2 rounded-lg text-xs border border-gray-700 min-h-[44px]">Desconto</button>
class="bg-black text-white font-bold px-3 h-11 rounded-lg text-xs border border-gray-700">Desconto</button>
<button data-action="click->validacao#marcarSelecionados" data-tipo="extraordinaria"
class="bg-purple-600 text-white font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Extra</button>
class="bg-purple-600 text-white font-bold px-3 h-11 rounded-lg text-xs">Extra</button>
<button type="button" data-action="validacao#limparSelecao" data-validacao-target="btnLimpar"
class="hidden text-gray-400 hover:text-white text-xs underline px-1">limpar</button>
</div>
<div class="ml-auto flex items-center gap-2">
<div class="order-4 ml-auto flex items-center gap-2">
<%# Oculta/mostra o resumo lateral (mais largura para a lista) %>
<button type="button" data-action="validacao#toggleResumo"
class="text-gray-400 hover:text-white text-sm border border-[#2a2a2a] hover:border-orange-500 px-3 py-2 rounded-lg whitespace-nowrap">
class="text-gray-400 hover:text-white text-sm border border-[#2a2a2a] hover:border-orange-500 px-3 h-11 rounded-lg whitespace-nowrap">
<%= icone :dashboard %> <span data-validacao-target="btnResumoLabel">Ocultar resumo</span>
</button>
<%= link_to params[:apenas_pendentes] == '1' ? 'Mostrar todas' : 'Apenas não classificadas',
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: @veiculos_sel.presence,
apenas_pendentes: params[:apenas_pendentes] == '1' ? nil : '1'),
class: 'text-orange-400 hover:text-orange-300 text-sm border border-orange-500/40 px-3 py-2 rounded-lg' %>
</div>
class: 'text-orange-400 hover:text-orange-300 text-sm border border-orange-500/40 px-3 h-11 rounded-lg flex items-center' %>
</div>
<%# Caixa de ferramentas — concentra os lançamentos avulsos (registro manual de
entrega e entrega de termo) num único botão com menu suspenso. Os modais e
controllers existentes são reaproveitados; só o gatilho mudou. %>
controllers existentes são reaproveitados; só o gatilho mudou.
Fica DENTRO da barra de ações (order-2) para não custar uma linha própria;
os modais são `fixed`, então não afetam o layout da faixa. %>
<% unless @consolidacao.finalizada? && current_user.operador? %>
<div class="relative mb-3"
<div class="order-3 relative"
data-controller="ferramentas apontamento-manual apontamento-termo"
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
data-apontamento-termo-apontar-termo-url-value="<%= apontar_termo_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
@@ -184,7 +205,7 @@
<%# Botão único + menu suspenso %>
<button type="button" data-action="ferramentas#toggle"
class="w-full md:w-auto inline-flex items-center justify-center gap-2 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-dashed border-[#3a3a3a] hover:border-orange-500 font-semibold px-4 py-3 rounded-lg min-h-[48px]">
class="inline-flex items-center justify-center gap-2 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-dashed border-[#3a3a3a] hover:border-orange-500 font-semibold px-4 h-11 rounded-lg text-sm whitespace-nowrap">
<%= icone :adicionar %> Adicionar lançamento
<%= icone :expandir, cor: 'text-gray-500', espaco: false %>
</button>
@@ -347,6 +368,9 @@
</div>
</div>
<% end %>
</div>
<%# Fim da barra de ações %>
</div>
<%# Fim do cabeçalho fixo — abaixo só a lista de NFs rola. %>
@@ -375,6 +399,7 @@
<%# Checkbox seleção em massa %>
<input type="checkbox" data-validacao-target="checkbox" value="<%= e.tracking_id %>"
data-action="change->validacao#atualizarSelecao"
class="rounded text-orange-500 bg-[#0a0a0a] border-[#2a2a2a] w-5 h-5">
<%# Dados da entrega %>

View File

@@ -71,28 +71,37 @@
</div>
<%# ── Filtros ──────────────────────────────────────────── %>
<%# Filtros em flex-wrap, NÃO em grade de 6 colunas fixas.
Motivo: os breakpoints do Tailwind (md:, lg:…) enxergam a JANELA, mas o
conteúdo perde 256px para a sidebar (`main.md:ml-64`). A mesma janela de
1280px tem duas larguras conforme o menu esteja aberto ou recolhido, então
`md:grid-cols-6` espremia os campos a ~150px e o texto do botão "Filtrar"
vazava para fora do fundo. Com flex-wrap + largura mínima, cada campo mantém
tamanho legível e a linha quebra sozinha conforme o espaço REAL. %>
<%= form_with url: consolidacoes_path, method: :get,
class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 grid grid-cols-1 md:grid-cols-6 gap-3' do |f| %>
class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 flex flex-wrap items-center gap-3' do |f| %>
<%= f.text_field :nome, value: params[:nome], placeholder: 'Buscar por nome…',
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %>
class: 'flex-1 basis-48 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %>
<%= f.select :status,
options_for_select([['Todos os status', ''], ['Rascunho', 'rascunho'], ['Finalizada', 'finalizada']], params[:status]),
{}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
{}, class: 'flex-1 basis-40 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
<%= f.select :pagamento,
options_for_select([['Pagamento (todos)', ''], ['Pagas', 'pago'], ['Parciais', 'parcial'], ['Pendentes', 'pendente']], params[:pagamento]),
{}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
{}, class: 'flex-1 basis-44 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
<%= f.date_field :inicio, value: params[:inicio],
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
class: 'flex-1 basis-36 max-w-[13rem] min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
<%= f.date_field :fim, value: params[:fim],
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
class: 'flex-1 basis-36 max-w-[13rem] min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
<div class="flex gap-2">
<%= f.submit 'Filtrar', class: 'flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-lg cursor-pointer' %>
<%= link_to 'Limpar', consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg' %>
<%# shrink-0 + whitespace-nowrap: os botões nunca são espremidos a ponto de
cortar o texto — se faltar espaço, a linha quebra e eles descem inteiros. %>
<div class="flex gap-2 shrink-0">
<%= f.submit 'Filtrar', class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-lg cursor-pointer px-5 py-2.5 whitespace-nowrap' %>
<%= link_to 'Limpar', consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg whitespace-nowrap' %>
</div>
<% end %>

View File

@@ -0,0 +1,45 @@
<%# Lista do ranking de motoristas — markup COMPARTILHADO pelas abas "Estimado" e
"Consolidado" do dashboard. Os dois conjuntos têm a mesma forma
(:nome, :valor, :entregas); só muda a origem dos números, então duplicar o
markup faria as duas abas divergirem visualmente na primeira alteração.
Locais: itens (array de hashes), vazio (texto do estado sem dados). %>
<% if itens.any? %>
<div class="space-y-3">
<%# A barra é proporcional ao VALOR, que é o número pelo qual as duas abas são
ordenadas — assim ela sempre encurta de cima para baixo.
⚠️ Não voltar a usar :entregas aqui: na aba Estimado dá no mesmo (valor =
qtd × preço), mas na Consolidada quem tem mais entregas nem sempre tem o
maior valor (bônus/retirada/termo mudam o preço unitário) e a barra do 3º
ficava MAIOR que a do 2º, contradizendo a ordem do ranking.
Zero vira barra vazia em vez de divisão por zero. %>
<% max = itens.map { |x| x[:valor].to_f }.max.to_f %>
<% itens.each_with_index do |m, i| %>
<div class="flex items-center gap-3">
<span class="text-xs font-bold w-5 text-center
<%= i == 0 ? 'text-yellow-400' : i == 1 ? 'text-gray-300' : i == 2 ? 'text-orange-600' : 'text-gray-400' %>">
<%= i + 1 %>
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<span class="text-white text-sm font-medium truncate"><%= m[:nome] %></span>
<span class="text-[#f97316] text-sm font-semibold ml-2 flex-shrink-0">
<%= moeda(m[:valor]) %>
</span>
</div>
<%# Barra de progresso %>
<div class="w-full bg-white/5 rounded-full h-1.5">
<div class="h-1.5 rounded-full bg-[#f97316]"
style="width: <%= max.zero? ? 0 : [(m[:valor] / max * 100).round, 100].min %>%"></div>
</div>
<span class="text-gray-400 text-xs"><%= m[:entregas] %> entregas</span>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-8 text-gray-400">
<div class="mb-2"><%= icone :caminhao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %></div>
<p class="text-sm"><%= vazio %></p>
</div>
<% end %>

View File

@@ -117,19 +117,31 @@
</form>
</div>
<%# CARDS GRANDES — KPIs %>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
<%# CARDS GRANDES — KPIs
auto-fit + minmax em vez de `xl:grid-cols-5`: o breakpoint xl olha a JANELA
(1280px), mas com a sidebar aberta sobram ~960px de conteúdo — 5 colunas de
~176px, e "R$ 82.692,00" em text-4xl era cortado pelo overflow-hidden do
card. Com auto-fit o número de colunas cai sozinho quando o menu está
aberto e sobe quando ele é recolhido, sem depender de breakpoint. %>
<div class="grid grid-cols-[repeat(auto-fit,minmax(13rem,1fr))] gap-4">
<%# Card 1: Valor estimado %>
<div class="sm:col-span-2 xl:col-span-1 bg-[#f97316] rounded-2xl p-6 relative overflow-hidden">
<%# Card 1: Valor estimado — sempre 2 faixas: é o maior número da tela %>
<div class="sm:col-span-2 bg-[#f97316] rounded-2xl p-6 relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-orange-400/20 to-transparent"></div>
<div class="relative">
<p class="text-orange-100 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :dinheiro, cor: nil, espaco: false %> Valor Estimado</p>
<p class="text-4xl font-black text-white mb-1">
<%# break-words: se ainda faltar espaço, quebra a linha em vez de cortar %>
<p class="text-3xl lg:text-4xl font-black text-white mb-1 break-words">
<%= moeda(@valor_estimado) %>
</p>
<%# "Atendidas" = concluídas + falhadas (motorista foi ao local e fechou
a visita). É a mesma base que a consolidação paga, por isso o rótulo
NÃO pode voltar a dizer "concluídas": o valor acima inclui as falhas. %>
<p class="text-orange-200 text-sm">
<%= @entregas_pagas %> entregas concluídas
<%= @entregas_atendidas %> entregas atendidas
</p>
<p class="text-orange-200/80 text-xs">
<%= @entregas_pagas %> concluídas · <%= @entregas_falhadas %> falhadas
</p>
</div>
</div>
@@ -178,7 +190,7 @@
</div>
<%# KPIs FINANCEIROS (consolidações finalizadas, já com filtro de operação) %>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<div class="grid grid-cols-[repeat(auto-fit,minmax(13rem,1fr))] gap-4">
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Custo total</p>
<p class="text-3xl font-black text-white"><%= moeda(@fin_custo_total) %></p>
@@ -250,42 +262,48 @@
<% end %>
</div>
<%# Top motoristas %>
<%# Top motoristas — DUAS ABAS sobre o mesmo ranking:
• Estimado = entregas atendidas × preço da entrega (mesma base do card
laranja do topo).
• Consolidado = o que foi REALMENTE fechado nas consolidações do período,
já com bônus/desconto/retirada aplicados, e a quantidade
exata de entregas que entrou no fechamento.
Os dois números divergem DE PROPÓSITO: o estimado cobre tudo que foi
atendido no período; o consolidado, só o que já entrou em consolidação
finalizada. Por isso cada aba diz de onde vem o seu número. %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :ranking, espaco: false %> Motoristas</h2>
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h2 class="text-lg font-semibold text-white flex items-center gap-2"><%= icone :ranking, espaco: false %> Motoristas</h2>
<% if @motoristas.any? %>
<div class="space-y-3">
<% @motoristas.each_with_index do |m, i| %>
<div class="flex items-center gap-3">
<span class="text-xs font-bold w-5 text-center
<%= i == 0 ? 'text-yellow-400' : i == 1 ? 'text-gray-300' : i == 2 ? 'text-orange-600' : 'text-gray-400' %>">
<%= i + 1 %>
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<span class="text-white text-sm font-medium truncate"><%= m[:nome] %></span>
<span class="text-[#f97316] text-sm font-semibold ml-2 flex-shrink-0">
<%= moeda(m[:valor]) %>
</span>
</div>
<%# Barra de progresso %>
<div class="w-full bg-white/5 rounded-full h-1.5">
<% max = @motoristas.first[:entregas].to_f %>
<div class="h-1.5 rounded-full bg-[#f97316]"
style="width: <%= [(m[:entregas] / max * 100).round, 100].min %>%"></div>
</div>
<span class="text-gray-400 text-xs"><%= m[:entregas] %> entregas</span>
<%# Mesmas pílulas da paginação de pagamentos (as classes estão duplicadas
no JS que troca a aba — mexeu aqui, mexa lá). %>
<div id="ranking-abas" class="flex gap-1.5">
<button type="button" data-aba="estimado"
class="px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors bg-[#f97316] text-white">
Estimado
</button>
<button type="button" data-aba="consolidado"
class="px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white">
Consolidado
</button>
</div>
</div>
<% end %>
<div id="ranking-painel-estimado">
<p class="text-gray-400 text-xs mb-3">Entregas atendidas × preço da entrega</p>
<%= render 'ranking_motoristas',
itens: @motoristas,
vazio: 'Nenhuma entrega atendida neste período' %>
</div>
<% else %>
<div class="text-center py-8 text-gray-400">
<div class="mb-2"><%= icone :caminhao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %></div>
<p class="text-sm">Nenhuma entrega concluída neste período</p>
<%# Sem JS a aba fica escondida — aceitável: o mesmo dado consolidado
também aparece no gráfico "Custo por motorista" logo abaixo. %>
<div id="ranking-painel-consolidado" class="hidden">
<p class="text-gray-400 text-xs mb-3">Valor fechado nas consolidações do período</p>
<%= render 'ranking_motoristas',
itens: @ranking_consolidado,
vazio: 'Nenhuma consolidação finalizada neste período' %>
</div>
<% end %>
</div>
</div>
@@ -445,7 +463,7 @@
<% if @por_operacao.any? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :operacao, espaco: false %> Entregas por Local</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
<% total_op = @por_operacao.values.sum.to_f %>
<% @por_operacao.each do |local, qtd| %>
<div class="bg-[#0a0a0a] rounded-xl p-4 border border-white/5 text-center">
@@ -565,6 +583,38 @@
})();
</script>
<%# Abas do ranking de motoristas (Estimado × Consolidado) %>
<script>
(function () {
// As classes repetem as do ERB porque a troca de aba reescreve o className
// inteiro — mesmo padrão da paginação de pagamentos acima.
const ATIVA = 'px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors bg-[#f97316] text-white';
const INATIVA = 'px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white';
function initAbasRanking() {
const abas = document.getElementById('ranking-abas');
if (!abas || abas.dataset.iniciado) return;
abas.dataset.iniciado = '1';
const botoes = Array.from(abas.querySelectorAll('button'));
botoes.forEach(function (btn) {
btn.addEventListener('click', function () {
botoes.forEach(function (b) {
const ativa = (b === btn);
const painel = document.getElementById('ranking-painel-' + b.dataset.aba);
b.className = ativa ? ATIVA : INATIVA;
if (painel) painel.classList.toggle('hidden', !ativa);
});
});
});
}
initAbasRanking();
document.addEventListener('turbo:load', initAbasRanking);
})();
</script>
<%# Filtro de operação — submete o form ao marcar/desmarcar %>
<script>
(function () {

View File

@@ -36,8 +36,15 @@
<p class="text-black/80 font-bold uppercase tracking-wide flex items-center gap-2"><%= icone :dinheiro, cor: nil, espaco: false %> Valor estimado deste mês</p>
<p class="text-black/70 text-sm mt-1"><%= icone :calendario, cor: nil %> <%= l @estimado_inicio, format: :short %> a <%= l @estimado_fim, format: :short %></p>
<p class="text-black font-black text-4xl sm:text-5xl mt-2 leading-tight whitespace-nowrap"><%= moeda(@valor_estimado) %></p>
<%# "Atendidas" = concluídas + sem sucesso. O rótulo NÃO pode voltar a dizer
"feitas e confirmadas": o valor acima inclui as visitas sem sucesso, que
a empresa também paga (o motorista foi ao local). Sem a segunda linha o
motorista não consegue conferir a conta. %>
<p class="text-black/80 text-base mt-2">
<%= @entregas_mes %> entregas feitas e confirmadas
<%= @entregas_mes %> entregas atendidas
</p>
<p class="text-black/70 text-sm mt-1">
<%= @entregas_concluidas %> entregues · <%= @entregas_falhadas %> sem sucesso (pagas também)
</p>
<p class="text-black/60 text-sm mt-3">
<%= icone :alerta, cor: nil %> Valor aproximado. O valor certo sai quando a empresa fechar o pagamento.

View File

@@ -0,0 +1,42 @@
<%# app/views/teste_mailer/teste.html.erb %>
<div style="font-family: Arial, sans-serif; max-width: 520px; margin: 0 auto; background: #0a0a0a; border-radius: 12px; overflow: hidden;">
<div style="background: #0a0a0a; padding: 24px; border-left: 6px solid #f97316;">
<h1 style="color: #ffffff; margin: 0; font-size: 20px;">REEM TRANSPORTE</h1>
<p style="color: #f97316; margin: 4px 0 0; font-size: 13px;">Sistema de Logística</p>
</div>
<div style="background: #ffffff; padding: 28px;">
<h2 style="color: #0a0a0a; margin-top: 0;">✅ Configuração de e-mail funcionando</h2>
<p style="color: #374151; font-size: 15px; line-height: 1.6;">
Se você está lendo esta mensagem, o servidor de e-mail cadastrado na tela
<strong>Configurações → Notificações e E-mail</strong> está enviando corretamente.
</p>
<table style="width: 100%; margin: 20px 0; border-collapse: collapse; font-size: 14px;">
<tr>
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Servidor</td>
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @config.smtp_address %>:<%= @config.smtp_port %></td>
</tr>
<tr>
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Remetente</td>
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @config.remetente_formatado %></td>
</tr>
<% if @usuario %>
<tr>
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Disparado por</td>
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @usuario.nome_display %></td>
</tr>
<% end %>
<tr>
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Data/hora</td>
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @quando.strftime('%d/%m/%Y às %H:%M') %></td>
</tr>
</table>
<p style="color: #9ca3af; font-size: 12px; text-align: center;">
Mensagem automática de teste — não é necessário responder.
</p>
</div>
</div>

252
bin/entregas_do_motorista Executable file
View File

@@ -0,0 +1,252 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Lista, DIRETO NA API do SimpliRoute, todas as visitas de um motorista dentro
# de um período.
#
# ⚠️ POR QUE É DIA A DIA (e não uma chamada só):
# A API NÃO tem filtro de intervalo. Testado contra a API real em 21/07/2026
# (bin/sondar_busca_nf): `planned_date_from/to`, `planned_date_after/before`,
# `__gte/__lte`, `since/until`, `start_date/end_date`, `date_from/to` — TODOS
# ignorados em silêncio (200 + o conjunto padrão inteiro). E SEM `planned_date`
# a API devolve um conjunto padrão (~2469 visitas) que NÃO cobre o histórico.
# Logo: `planned_date` é obrigatório e o período vira N requisições, uma por dia,
# disparadas em paralelo.
#
# ⚠️ Não filtramos motorista no servidor: `search=` só foi comprovado para NF, e
# parâmetro que a API não registra é IGNORADO em silêncio (devolve o dia inteiro
# com HTTP 200). Então baixamos o dia inteiro e filtramos AQUI — é lento
# (~3,8 MB / ~9 s por dia) mas é a única forma correta.
#
# SÓ LEITURA: o script faz apenas GET. Não altera nada.
#
# Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN):
#
# SIMPLIROUTE_TOKEN=xxx bin/entregas_do_motorista \
# --motorista "JOSE DA SILVA" --de 2026-08-01 --ate 2026-08-14
#
# ... --csv /tmp/entregas.csv grava o resultado em CSV
# ... --listar-motoristas só mostra quem aparece no período (sem filtrar)
# ... --campos mostra as chaves cruas de uma visita (diagnóstico)
#
# Alternativa MUITO mais barata quando não precisa do dado ao vivo: o espelho
# local (model Entrega, tabela db_reem_simplerout_2026) já tem a coluna `driver`
# e responde isso numa query só:
#
# Entrega.no_periodo('2026-08-01', '2026-08-14').do_motorista('JOSE DA SILVA')
#
# O espelho sincroniza depois da API, então só use este script quando precisar
# do estado ATUAL ou desconfiar da sync.
require 'net/http'
require 'json'
require 'uri'
require 'date'
require 'csv'
BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com')
TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s
THREADS = 6 # mesmo lote usado no sondar_busca_nf; acima disso a API estrangula
def sair(msg)
warn msg
exit 1
end
sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty?
motorista = nil
de = nil
ate = nil
csv_path = nil
listar = false
campos = false
ARGV.each_with_index do |a, i|
case a
when '--motorista' then motorista = ARGV[i + 1]
when '--de' then de = ARGV[i + 1]
when '--ate', '--até' then ate = ARGV[i + 1]
when '--csv' then csv_path = ARGV[i + 1]
when '--listar-motoristas' then listar = true
when '--campos' then campos = true
end
end
sair('Uso: bin/entregas_do_motorista --motorista "NOME" --de YYYY-MM-DD --ate YYYY-MM-DD') if de.to_s.empty? || ate.to_s.empty?
sair('Informe --motorista, ou use --listar-motoristas.') if motorista.to_s.empty? && !listar && !campos
inicio = begin
Date.parse(de)
rescue ArgumentError
sair("Data inválida em --de: #{de}")
end
fim = begin
Date.parse(ate)
rescue ArgumentError
sair("Data inválida em --ate: #{ate}")
end
sair('--de é depois de --ate.') if inicio > fim
DIAS = (inicio..fim).to_a
# Normaliza nome para comparar: sem acento, sem espaço duplo, maiúsculo.
# O nome no SimpliRoute é digitado por gente — "José da Silva" e "JOSE DA SILVA"
# são o mesmo motorista.
def normalizar(nome)
nome.to_s
.unicode_normalize(:nfd).gsub(/\p{Mn}/, '')
.upcase.gsub(/\s+/, ' ').strip
end
def requisicao(caminho)
uri = URI.join(BASE, caminho)
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Token #{TOKEN}"
req['Accept'] = 'application/json'
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
open_timeout: 10, read_timeout: 180) { |h| h.request(req) }
return [res.code.to_i, nil] unless res.code.to_i == 200
[200, (res.body.to_s.empty? ? nil : JSON.parse(res.body))]
rescue StandardError => e
[0, "ERRO: #{e.class}: #{e.message}"]
end
# A resposta pode ser lista crua OU paginada ({count, next, results}).
def itens(corpo)
return corpo if corpo.is_a?(Array)
return Array(corpo['results']) if corpo.is_a?(Hash) && corpo.key?('results')
[]
end
# Onde está o nome do motorista numa visita. A lista-por-data às vezes traz o
# campo achatado ('driver'), às vezes só o id, às vezes um hash aninhado — por
# isso procuramos em várias formas em vez de fixar uma. Se nada casar, o script
# avisa e o --campos mostra o formato real.
CHAVES_MOTORISTA = %w[driver driver_name route_driver driver_full_name].freeze
def nome_do_motorista(visita)
CHAVES_MOTORISTA.each do |k|
v = visita[k]
next if v.nil? || v == '' || v.is_a?(Numeric)
return v if v.is_a?(String)
return (v['name'] || v['full_name'] ||
[v['first_name'], v['last_name']].compact.join(' ')) if v.is_a?(Hash)
end
nil
end
puts "Base: #{BASE}"
puts "Período: #{inicio.strftime('%d/%m/%Y')} a #{fim.strftime('%d/%m/%Y')} (#{DIAS.size} dia(s), #{THREADS} em paralelo)"
puts "Motorista: #{motorista}" if motorista
puts '(a API não filtra por intervalo nem por motorista — baixando dia a dia; pode levar ~1 min)'
puts
# ── Varredura dia a dia, em lotes ────────────────────────────────────────────
t0 = Time.now
visitas = []
falhas = []
mutex = Mutex.new
DIAS.each_slice(THREADS) do |lote|
lote.map do |dia|
Thread.new do
cod, corpo = requisicao("/v1/routes/visits/?planned_date=#{dia.iso8601}")
lista = cod == 200 ? itens(corpo) : []
mutex.synchronize do
if cod == 200
# planned_date às vezes não volta na visita — garantimos o dia pedido.
lista.each { |v| v['_dia'] = dia.iso8601 }
visitas.concat(lista)
print '.'
else
falhas << [dia, cod, corpo]
print 'x'
end
end
end
end.each(&:join)
end
puts "\nBaixadas #{visitas.size} visita(s) em #{(Time.now - t0).round(1)}s"
falhas.each { |d, c, e| warn " ⚠️ #{d}: HTTP #{c} #{e.inspect[0, 120]}" }
sair('Nenhuma visita voltou — período sem operação ou token sem acesso.') if visitas.empty?
# ── Diagnóstico do formato ───────────────────────────────────────────────────
if campos
puts "\nChaves de uma visita crua:"
puts visitas.first.keys.sort.inspect
puts "\nExemplo (recortado):"
puts JSON.pretty_generate(visitas.first)[0, 2000]
exit 0
end
com_nome = visitas.count { |v| nome_do_motorista(v) }
if com_nome.zero?
warn "\n⚠ NENHUMA visita trouxe nome de motorista nas chaves #{CHAVES_MOTORISTA.inspect}."
warn ' A lista-por-data pode estar devolvendo só o id da rota. Rode com --campos'
warn ' para ver o formato real, ou use o espelho local (Entrega#driver).'
exit 1
end
warn "\n⚠ #{visitas.size - com_nome} visita(s) sem motorista preenchido (ficam de fora do filtro)." if com_nome < visitas.size
# ── Só listar quem apareceu ──────────────────────────────────────────────────
if listar
puts "\nMotoristas no período:"
visitas.group_by { |v| nome_do_motorista(v) }
.reject { |k, _| k.nil? }
.sort_by { |_, vs| -vs.size }
.each { |nome, vs| puts " #{vs.size.to_s.rjust(5)} #{nome}" }
exit 0
end
# ── Filtro pelo motorista ────────────────────────────────────────────────────
alvo = normalizar(motorista)
dele = visitas.select { |v| normalizar(nome_do_motorista(v)) == alvo }
if dele.empty?
puts "\nNenhuma visita de #{motorista.inspect} no período."
parecidos = visitas.map { |v| nome_do_motorista(v) }.compact.uniq
.select { |n| normalizar(n).include?(alvo) || alvo.include?(normalizar(n)) }
puts "Nomes parecidos que existem: #{parecidos.inspect}" if parecidos.any?
puts 'Use --listar-motoristas para ver a grafia exata.'
exit 0
end
dele.sort_by! { |v| [v['_dia'].to_s, v['checkout_time'].to_s] }
puts "\n#{'─' * 100}"
puts "#{dele.size} visita(s) de #{motorista}"
puts '─' * 100
printf("%-11s %-10s %-9s %-19s %-40s\n", 'DATA', 'NF', 'STATUS', 'CHECKOUT', 'DESTINO')
dele.each do |v|
printf("%-11s %-10s %-9s %-19s %-40s\n",
Date.parse(v['_dia']).strftime('%d/%m/%Y'),
v['reference'].to_s[0, 10],
v['status'].to_s[0, 9],
v['checkout_time'].to_s[0, 19].tr('T', ' '),
v['title'].to_s[0, 40])
end
puts '─' * 100
por_status = dele.group_by { |v| v['status'].to_s }.transform_values(&:size)
puts "Total: #{dele.size} " + por_status.sort_by { |_, n| -n }.map { |s, n| "#{s}=#{n}" }.join(' ')
atendidas = dele.count { |v| %w[completed failed].include?(v['status'].to_s) && v['checkout_time'].to_s != '' }
puts "Atendidas (foi ao local, com checkout): #{atendidas}"
puts "Dias com entrega: #{dele.map { |v| v['_dia'] }.uniq.size}"
# ── CSV ──────────────────────────────────────────────────────────────────────
if csv_path
CSV.open(csv_path, 'w') do |csv|
csv << ['Data', 'NF', 'Tracking', 'ID visita', 'Motorista', 'Status',
'Checkout', 'Título', 'Endereço', 'Motivo (UUID)', 'Comentário']
dele.each do |v|
csv << [Date.parse(v['_dia']).strftime('%d/%m/%Y'), v['reference'], v['tracking_id'], v['id'],
nome_do_motorista(v), v['status'], v['checkout_time'], v['title'], v['address'],
v['checkout_observation'], v['checkout_comment']]
end
end
puts "\nCSV gravado em #{csv_path}"
end

198
bin/sondar_busca_nf Executable file
View File

@@ -0,0 +1,198 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Descobre se a API do SimpliRoute aceita buscar visita por NF e/ou por período
# — a doc oficial só documenta `?planned_date=` de UM dia.
#
# ── RESULTADO DA RODADA DE 21/07/2026 (contra a API real) ───────────────────
# ✔ `&search=<NF>` FUNCIONA (não documentado): dia inteiro cai de ~3,8 MB/9 s
# para ~1 KB/0,6 s. É o que a tela usa hoje.
# ✘ `reference`, `reference_id`, `q`, `title`, `reference__*` — IGNORADOS em
# silêncio (200 + dia inteiro).
# ✘ Intervalo (`planned_date_from/to`, `since/until`, `__gte/__lte`,
# `date_from/to`) — TODOS ignorados: devolveram 2469, igual ao controle sem
# parâmetro nenhum.
# ✘ Sem `planned_date` a API devolve um conjunto padrão (~2469) que NÃO cobre
# o histórico — `?search=<NF>` sozinho escondeu a visita mais antiga da NF.
# ⇒ `planned_date` continua obrigatório; a busca por NF é dia a dia + search.
#
# Rode de novo se desconfiar que a API mudou.
#
# SÓ LEITURA: o script faz apenas GET/OPTIONS. Não altera nada.
#
# Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN):
#
# SIMPLIROUTE_TOKEN=xxx bin/sondar_busca_nf --nf 82891 --data 2026-07-17
# SIMPLIROUTE_TOKEN=xxx bin/sondar_busca_nf --nf 82891 --data 2026-07-17 --sem-data
#
# --data deve ser um dia em que a NF EXISTE (é a referência de comparação).
# --sem-data testa a consulta sem `planned_date` nenhum; pode ser lenta ou vir
# gigante, por isso fica de fora por padrão.
#
# ⚠️ Por que comparar contagens: a API é Django REST. Parâmetro que o backend
# não registra é IGNORADO em silêncio — devolve 200 e a lista inteira. Então
# "voltou 200 com resultados" NÃO prova que filtrou. A prova é o filtro DIMINUIR
# o resultado do dia e sobrar só a NF pedida.
require 'net/http'
require 'json'
require 'uri'
require 'date'
BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com')
TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s
def sair(msg)
warn msg
exit 1
end
sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty?
nf = nil
data = nil
sem_data = false
ARGV.each_with_index do |a, i|
nf = ARGV[i + 1] if a == '--nf'
data = ARGV[i + 1] if a == '--data'
sem_data = true if a == '--sem-data'
end
sair('Uso: bin/sondar_busca_nf --nf <numero> --data <YYYY-MM-DD> [--sem-data]') if nf.to_s.empty? || data.to_s.empty?
def requisicao(caminho, metodo: :get)
uri = URI.join(BASE, caminho)
req = metodo == :options ? Net::HTTP::Options.new(uri) : Net::HTTP::Get.new(uri)
req['Authorization'] = "Token #{TOKEN}"
req['Accept'] = 'application/json'
t0 = Time.now
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
open_timeout: 10, read_timeout: 120) { |h| h.request(req) }
corpo = begin
res.body.to_s.empty? ? nil : JSON.parse(res.body)
rescue JSON::ParserError
res.body.to_s[0, 200]
end
[res.code.to_i, corpo, res.body.to_s.bytesize, Time.now - t0]
rescue StandardError => e
[0, "ERRO: #{e.class}: #{e.message}", 0, Time.now - t0]
end
# A resposta pode ser lista crua OU paginada ({count, next, results}). No caso
# paginado o `count` já é o total — é a informação mais barata que existe aqui.
def itens(corpo)
return corpo if corpo.is_a?(Array)
return Array(corpo['results']) if corpo.is_a?(Hash) && corpo.key?('results')
[]
end
def total(corpo, lista)
corpo.is_a?(Hash) && corpo['count'] ? corpo['count'] : lista.size
end
def titulo(t)
puts "\n#{'─' * 70}\n#{t}\n#{'─' * 70}"
end
puts "Base: #{BASE} NF: #{nf} Data de referência: #{data}"
# ── 1. Linha de base: o dia inteiro, do jeito que a tela faz hoje ────────────
titulo("1. LINHA DE BASE · GET /v1/routes/visits/?planned_date=#{data}")
cod, corpo, bytes, seg = requisicao("/v1/routes/visits/?planned_date=#{data}")
sair("Falhou (HTTP #{cod}): #{corpo.inspect}") unless cod == 200
base_lista = itens(corpo)
base_total = total(corpo, base_lista)
base_ids = base_lista.map { |v| v['id'] }
da_nf = base_lista.select { |v| v['reference'].to_s == nf.to_s }
puts " HTTP 200 em #{seg.round(2)}s — #{bytes} bytes"
puts " visitas no dia .......... #{base_total}"
puts " paginado? ............... #{corpo.is_a?(Hash) ? "sim (count=#{corpo['count']}, next=#{corpo['next'].inspect})" : 'não (lista crua)'}"
puts " visitas com a NF #{nf} ... #{da_nf.size}"
da_nf.each { |v| puts " id=#{v['id']} tracking=#{v['tracking_id'].inspect} status=#{v['status']} data=#{v['planned_date']}" }
puts " ⚠️ tracking_id vem NULO na lista-por-data" if da_nf.any? && da_nf.all? { |v| v['tracking_id'].nil? }
sair("A NF #{nf} não aparece em #{data}. Passe uma data em que ela exista — sem isso não há como comparar.") if da_nf.empty?
# ── 2. Filtro por NF: o parâmetro DIMINUI o resultado do dia? ────────────────
# Se a contagem não mudar, o parâmetro foi ignorado (mesmo com HTTP 200).
titulo('2. FILTRO POR NF · <dia> + <param>=NF (diminuiu = filtro real)')
%w[reference reference_id search q title reference__exact reference__icontains].each do |param|
cod, corpo, _b, seg = requisicao("/v1/routes/visits/?planned_date=#{data}&#{param}=#{nf}")
lista = itens(corpo)
qtd = total(corpo, lista)
veredito =
if cod != 200 then "HTTP #{cod} — não aceito"
elsif qtd == base_total then 'IGNORADO (mesma contagem do dia inteiro)'
elsif qtd.zero? then 'aceito porém vazio — filtrou demais'
elsif lista.all? { |v| v['reference'].to_s == nf.to_s } then "★ FILTRA DE VERDADE — #{qtd} visita(s), todas da NF"
else "reduziu p/ #{qtd} mas veio NF de fora"
end
puts " #{param.ljust(22)} #{veredito} (#{seg.round(2)}s)"
end
# ── 3. Filtro por período ────────────────────────────────────────────────────
# Comparação: o intervalo <data>..<data+1> tem que devolver MAIS que só <data>.
dia2 = (Date.parse(data) + 1).to_s
titulo("3. FILTRO POR PERÍODO · #{data} a #{dia2} (> #{base_total} = intervalo respeitado)")
[
%w[planned_date_from planned_date_to],
%w[planned_date_after planned_date_before],
%w[planned_date__gte planned_date__lte],
%w[since until],
%w[start_date end_date],
%w[date_from date_to]
].each do |de, ate|
cod, corpo, _b, seg = requisicao("/v1/routes/visits/?#{de}=#{data}&#{ate}=#{dia2}")
lista = itens(corpo)
qtd = total(corpo, lista)
veredito =
if cod != 200 then "HTTP #{cod} — não aceito"
elsif qtd > base_total then "★ RESPEITA O INTERVALO — #{qtd} visitas (dia sozinho: #{base_total})"
elsif qtd == base_total then "mesma contagem do dia — inconclusivo/ignorado"
else "#{qtd} visitas — menos que o dia sozinho, estranho"
end
puts " #{"#{de}/#{ate}".ljust(38)} #{veredito} (#{seg.round(2)}s)"
end
# ── 4. OPTIONS: o DRF às vezes lista os filtros aceitos ──────────────────────
titulo('4. OPTIONS /v1/routes/visits/ (metadados do endpoint)')
cod, corpo, = requisicao('/v1/routes/visits/', metodo: :options)
if cod == 200 && corpo.is_a?(Hash)
puts " métodos ... #{corpo['renders'] ? corpo.slice('name', 'description', 'parses').inspect[0, 300] : corpo.keys.inspect}"
filtros = corpo['filters'] || corpo['filter_fields'] || corpo.dig('actions', 'GET')
puts " filtros ... #{filtros ? filtros.inspect[0, 500] : '(não declarados)'}"
else
puts " HTTP #{cod} — #{corpo.inspect[0, 200]}"
end
# ── 5. Sem planned_date nenhum — a pergunta direta ───────────────────────────
unless sem_data
puts "\n(Pulei o teste SEM planned_date. Rode com --sem-data para incluir — pode demorar ou vir gigante.)"
exit 0
end
titulo('5. SEM planned_date · a API devolve a NF inteira de uma vez?')
[
"/v1/routes/visits/?reference=#{nf}",
"/v1/routes/visits/?search=#{nf}",
'/v1/routes/visits/'
].each do |caminho|
cod, corpo, bytes, seg = requisicao(caminho)
lista = itens(corpo)
qtd = total(corpo, lista)
casam = lista.count { |v| v['reference'].to_s == nf.to_s }
puts " GET #{caminho}"
if cod == 200
puts " #{qtd} visita(s), #{casam} com a NF #{nf} — #{bytes} bytes em #{seg.round(2)}s"
datas = lista.select { |v| v['reference'].to_s == nf.to_s }.map { |v| v['planned_date'] }.uniq
puts " datas da NF: #{datas.inspect}" if datas.any?
puts ' ★ RESOLVE TUDO: a NF veio inteira, sem varrer dia a dia' if casam > 1 && casam == qtd
else
puts " HTTP #{cod} — #{corpo.inspect[0, 200]}"
end
end

View File

@@ -1,5 +1,12 @@
Devise.setup do |config|
# Fallback: só é consultado se o `default from:` do mailer sumir.
config.mailer_sender = ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br')
# Faz Devise::Mailer herdar de ApplicationMailer para que o "esqueci minha
# senha" use o SMTP e o remetente gravados no banco (tela /admin/
# configuracao_notificacao). Efeito colateral: os e-mails do Devise passam a
# usar o layout app/views/layouts/mailer.html.erb.
config.parent_mailer = 'ApplicationMailer'
require 'devise/orm/active_record'
config.case_insensitive_keys = [:email]
config.strip_whitespace_keys = [:email]

View File

@@ -3,4 +3,7 @@
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.irregular 'consolidacao', 'consolidacoes'
inflect.irregular 'configuracao', 'configuracoes'
# Sem esta linha, ConfiguracaoNotificacao procuraria a tabela
# "configuracao_notificacaos" (o irregular acima só casa no FIM da palavra).
inflect.irregular 'notificacao', 'notificacoes'
end

View File

@@ -1,5 +1,12 @@
# config/initializers/smtp.rb
# Configuração de e-mail 100% via ENV — só ativa se SMTP_USERNAME existir
#
# FALLBACK de e-mail via ENV — só ativa se SMTP_USERNAME existir.
#
# A fonte de verdade preferencial é o BANCO (ConfiguracaoNotificacao, editável
# em /admin/configuracao_notificacao). O ApplicationMailer passa as settings do
# banco em `delivery_method_options`, que o ActionMailer MERGEIA por cima do que
# está configurado aqui. Ou seja: banco > .env, e o que estiver só aqui continua
# valendo enquanto a tela não for preenchida.
if ENV['SMTP_USERNAME'].present?
Rails.application.config.action_mailer.delivery_method = :smtp
Rails.application.config.action_mailer.smtp_settings = {

View File

@@ -89,10 +89,17 @@ Rails.application.routes.draw do
end
resources :auditoria_logs, only: [:index]
# SMTP e WhatsApp (Twilio) gravados no banco — só admin.
# Sem rotas `testar_*`: os botões de teste são submits do próprio form
# (params[:acao]), para o teste usar exatamente o que está na tela.
resource :configuracao_notificacao, only: [:show, :update],
controller: 'configuracao_notificacoes'
# Edição de lançamento do SimpliRoute (correção de status/motivo/comentário)
# — só admin. Grava direto na API do SimpliRoute + AuditoriaLog.
resource :edicao_lancamento, only: [:show], controller: 'edicao_lancamentos' do
get :buscar # NF -> dados da visita (JSON)
get :buscar # NF -> lançamentos daquela NF (JSON; pode haver + de um)
get :carregar # visit_id -> card de um lançamento escolhido (JSON)
patch :atualizar # aplica a alteração na API
get :historico # histórico da visita na API (JSON, best-effort)
end

View File

@@ -11,3 +11,11 @@ set :environment, ENV.fetch("RAILS_ENV", "production")
every "*/30 8-18 * * *" do
rake "historico:atualizar"
end
# 01h — busca na API do SimpliRoute o número de série da base (aparelho) das
# visitas dos últimos 45 dias. É o único campo da planilha do cliente que o
# espelho não traz, e a API cobra ~9 s por DIA varrido: fora do horário
# comercial não atrapalha ninguém. Ver SincronizarSeriesAparelhoJob.
every "0 1 * * *" do
rake "simpli_route:series"
end

View File

@@ -0,0 +1,26 @@
# Guarda o "Número de Série da Base (Aparelho)" que o motorista registra no
# checkout do SimpliRoute.
#
# POR QUE UMA TABELA NOSSA: o campo existe como coluna no espelho
# (db_reem_simplerout_2026.numero_de_serie_da_base_aparelho), mas o processo que
# alimenta o espelho NUNCA o preenche — conferido em fev/mar/mai/jul/2026, 0 de
# ~6.000 linhas. O espelho é READ-ONLY (jamais escrever nele), então o job que
# busca o dado na API do SimpliRoute grava aqui, e a planilha do cliente faz o
# LEFT JOIN por tracking_id.
class CreateSeriesAparelho < ActiveRecord::Migration[7.1]
def change
create_table :series_aparelho do |t|
# tracking_id (SR...) é a chave do espelho — é por ele que a planilha casa.
t.string :tracking_id, null: false
t.string :reference_id # NF, só para conferência/depuração
t.string :numero_serie, null: false
t.date :planned_date # data varrida na API
t.datetime :sincronizado_em, null: false
t.timestamps
end
add_index :series_aparelho, :tracking_id, unique: true
add_index :series_aparelho, :planned_date
end
end

View File

@@ -0,0 +1,62 @@
# Configuração de SMTP e WhatsApp (Twilio) gravada no BANCO, editável pelo ADM
# em /admin/configuracao_notificacao — antes disso tudo vinha só do .env e mudar
# um servidor de e-mail exigia editar o arquivo no servidor e reiniciar.
#
# Tabela SINGLETON: uma linha só. `singleton_guard` com índice único é o que
# impede dois workers Puma criarem linhas concorrentes no primeiro acesso.
#
# As credenciais NÃO são importadas do ENV aqui de propósito: os campos nascem
# vazios e `smtp_ativo`/`whatsapp_ativo` respeitam as flags antigas, então
# `smtp_pronto?`/`whatsapp_pronto?` dão false e o app segue usando o .env
# exatamente como hoje até o admin preencher a tela.
class CreateConfiguracaoNotificacoes < ActiveRecord::Migration[7.1]
def up
create_table :configuracao_notificacoes do |t|
t.integer :singleton_guard, null: false, default: 0
# ── E-mail / SMTP ───────────────────────────────────────
t.boolean :smtp_ativo, null: false, default: false
t.string :smtp_address
t.integer :smtp_port, null: false, default: 587
t.string :smtp_username
t.text :smtp_password_cifrado # AES-256-GCM (concern AtributoCifrado)
t.string :smtp_domain
t.string :smtp_autenticacao, null: false, default: 'plain'
t.string :remetente_email
t.string :remetente_nome
# ── Destinatários administrativos ───────────────────────
t.string :email_admin
t.string :whatsapp_admin
# ── Notificação aos motoristas ──────────────────────────
t.boolean :email_notificacoes_ativo, null: false, default: false
# ── WhatsApp / Twilio ───────────────────────────────────
t.boolean :whatsapp_ativo, null: false, default: false
t.string :twilio_account_sid
t.text :twilio_auth_token_cifrado # AES-256-GCM
t.string :twilio_from
t.timestamps
end
add_index :configuracao_notificacoes, :singleton_guard, unique: true
# Herda as flags que hoje moram em `configuracoes` (chave/valor) e que nunca
# tiveram UI. SQL cru de propósito: migration não deve depender de model.
whats = select_value("SELECT valor FROM configuracoes WHERE chave = 'notificacao_whatsapp'")
email = select_value("SELECT valor FROM configuracoes WHERE chave = 'notificacao_email'")
execute(<<~SQL.squish)
INSERT INTO configuracao_notificacoes
(singleton_guard, whatsapp_ativo, email_notificacoes_ativo, smtp_ativo,
smtp_port, smtp_autenticacao, created_at, updated_at)
VALUES (0, #{whats == 'true'}, #{email == 'true'}, false, 587, 'plain', NOW(), NOW())
SQL
end
def down
drop_table :configuracao_notificacoes
end
end

View File

@@ -0,0 +1,118 @@
# lib/tasks/entregas_motorista.rake
#
# Lista as entregas de um motorista num período lendo o ESPELHO LOCAL
# (model Entrega / tabela db_reem_simplerout_2026) — sem tocar na API do
# SimpliRoute, logo sem precisar de SIMPLIROUTE_TOKEN.
#
# Por que aqui e não na API: a API não tem filtro de intervalo nem de motorista
# (ver bin/entregas_do_motorista), então um período vira N requisições de vários
# MB. No espelho é uma query só. O preço é a defasagem da sync — se precisar do
# estado AO VIVO, use bin/entregas_do_motorista.
#
# SÓ LEITURA: Entrega#readonly? é true; nada aqui escreve.
#
# rake "entregas:motorista[JOSE DA SILVA,2026-08-01,2026-08-14]"
# rake "entregas:motorista[JOSE DA SILVA,2026-08-01,2026-08-14,/tmp/saida.csv]"
# rake "entregas:motoristas[2026-08-01,2026-08-14]" # quem operou no período
#
# ⚠️ O nome tem que ser a grafia EXATA da coluna `driver` (do_motorista usa
# igualdade). Rode `entregas:motoristas` primeiro para ver a lista; se o nome
# passado não bater exatamente, a task sugere os parecidos em vez de devolver
# vazio silenciosamente.
#
# ⚠️ Período pelo `planned_date` (dia planejado da visita), que é o mesmo eixo
# do dashboard. As análises FINANCEIRAS usam `checkout` (data real da entrega) —
# são recortes diferentes; passe FONTE=checkout para usar esse outro eixo.
namespace :entregas do
desc 'Entregas de um motorista no período, lidas do espelho local (sem API)'
task :motorista, %i[nome inicio fim csv] => :environment do |_t, args|
nome = args[:nome].to_s.strip
inicio = args[:inicio].to_s.strip
fim = args[:fim].to_s.strip
if nome.empty? || inicio.empty? || fim.empty?
abort 'Uso: rake "entregas:motorista[NOME,YYYY-MM-DD,YYYY-MM-DD,/caminho/opcional.csv]"'
end
d1 = Date.parse(inicio)
d2 = Date.parse(fim)
abort 'Início é depois do fim.' if d1 > d2
# planned_date = dia planejado; checkout = dia em que a entrega ocorreu.
por_checkout = ENV['FONTE'].to_s.casecmp('checkout').zero?
base = Entrega.da_conta_gade
base = por_checkout ? base.no_periodo_checkout(d1, d2) : base.no_periodo(d1, d2)
entregas = base.do_motorista(nome).order(:planned_date, :checkout).to_a
if entregas.empty?
puts "Nenhuma entrega de #{nome.inspect} entre #{d1.strftime('%d/%m/%Y')} e #{d2.strftime('%d/%m/%Y')}."
# Nome quase certo é o erro mais comum aqui — mostrar o vizinho evita
# concluir "o motorista não rodou" quando na verdade a grafia divergiu.
alvo = nome.upcase
parecidos = Entrega.motoristas_ativos(inicio: d1, fim: d2)
.select { |n| n.upcase.include?(alvo) || alvo.include?(n.upcase) }
puts "Nomes parecidos no período: #{parecidos.inspect}" if parecidos.any?
puts 'Rode: rake "entregas:motoristas[' + "#{d1},#{d2}" + ']" para ver a grafia exata.'
next
end
eixo = por_checkout ? 'checkout (data real)' : 'planned_date (dia planejado)'
puts "#{'─' * 104}"
puts "#{entregas.size} entrega(s) de #{nome} · #{d1.strftime('%d/%m/%Y')} a #{d2.strftime('%d/%m/%Y')} · eixo: #{eixo}"
puts '─' * 104
printf("%-11s %-10s %-10s %-10s %-17s %-38s\n", 'DATA', 'NF', 'VEÍCULO', 'STATUS', 'CHECKOUT', 'DESTINO')
entregas.each do |e|
printf("%-11s %-10s %-10s %-10s %-17s %-38s\n",
e.planned_date&.strftime('%d/%m/%Y').to_s,
e.reference_id.to_s[0, 10],
e.vehicle.to_s[0, 10],
e.status.to_s[0, 10],
e.checkout&.strftime('%d/%m %H:%M').to_s,
e.local.to_s[0, 38])
end
puts '─' * 104
por_status = entregas.group_by { |e| e.status.to_s }.transform_values(&:size)
puts "Total: #{entregas.size} " + por_status.sort_by { |_, n| -n }.map { |s, n| "#{s}=#{n}" }.join(' ')
# "Atendidas" = foi ao local (sucesso OU insucesso) com checkout — é o
# conjunto elegível da consolidação, e não o mesmo que "entregues".
puts "Entregues (completed + checkout) ..... #{entregas.count { |e| e.concluida? && e.checkout_registrado? }}"
puts "Insucessos (failed) .................. #{entregas.count(&:falhada?)}"
puts "Atendidas (foi ao local c/ checkout) . #{entregas.count { |e| Entrega::STATUS_ATENDIDO.include?(e.status) && e.checkout_registrado? }}"
puts "Dias com entrega ..................... #{entregas.map { |e| e.planned_date&.to_date }.compact.uniq.size}"
puts "Veículos ............................. #{entregas.map(&:vehicle).compact.reject(&:empty?).uniq.join(', ')}"
if args[:csv].present?
require 'csv'
CSV.open(args[:csv], 'w') do |csv|
csv << ['Data planejada', 'NF', 'Tracking', 'Motorista', 'Veículo', 'Status', 'Checkout', 'Local', 'Endereço', 'Motivo']
entregas.each do |e|
csv << [e.planned_date, e.reference_id, e.tracking_id, e.driver, e.vehicle,
e.status, e.checkout, e.local, e.try(:address), e.try(:observation)]
end
end
puts "\nCSV gravado em #{args[:csv]}"
end
end
desc 'Motoristas que aparecem no espelho dentro do período (grafia exata + volume)'
task :motoristas, %i[inicio fim] => :environment do |_t, args|
abort 'Uso: rake "entregas:motoristas[YYYY-MM-DD,YYYY-MM-DD]"' if args[:inicio].blank? || args[:fim].blank?
d1 = Date.parse(args[:inicio])
d2 = Date.parse(args[:fim])
contagem = Entrega.da_conta_gade.no_periodo(d1, d2)
.where.not(driver: [nil, '']).group(:driver).count
if contagem.empty?
puts "Nenhuma entrega no espelho entre #{d1.strftime('%d/%m/%Y')} e #{d2.strftime('%d/%m/%Y')}."
next
end
puts "Motoristas · #{d1.strftime('%d/%m/%Y')} a #{d2.strftime('%d/%m/%Y')} (#{contagem.values.sum} entregas)"
contagem.sort_by { |_, n| -n }.each { |nome, n| puts " #{n.to_s.rjust(5)} #{nome}" }
end
end

View File

@@ -0,0 +1,42 @@
# lib/tasks/series_aparelho.rake
#
# Sincroniza o "Número de Série da Base (Aparelho)" da API do SimpliRoute para a
# tabela series_aparelho (ver SincronizarSeriesAparelhoJob).
# Log padrão das tarefas do projeto (ver historico.rake): uma linha de início,
# uma de resultado, e o erro sobe para o cron registrar. É lambda (e não método)
# para não pendurar um `executar` global no Object.
executar = lambda do |&bloco|
puts "[#{Time.current}] Sincronizando número de série da base (SimpliRoute)..."
r = bloco.call
puts "[#{Time.current}] OK — #{r[:datas]} data(s) varrida(s), #{r[:visitas]} visita(s), #{r[:series]} série(s) gravada(s)."
puts "[#{Time.current}] AVISO — #{r[:falhas].size} data(s) sem resposta da API: #{r[:falhas].map { |f| f[:data] }.join(', ')}" if r[:falhas].present?
rescue StandardError => e
puts "[#{Time.current}] ERRO: #{e.message}"
Rails.logger.error("[simpli_route:series] #{e.message}\n#{e.backtrace.first(5).join("\n")}")
raise
end
namespace :simpli_route do
desc 'Sincroniza o número de série da base dos últimos 45 dias (cron diário)'
task series: :environment do
executar.call { SincronizarSeriesAparelhoJob.perform_now }
end
desc 'Backfill de UMA operação inteira — rake "simpli_route:series_operacao[gade_entregas_ubs_sudeste_jul_2026]"'
task :series_operacao, [:tabela] => :environment do |_t, args|
tabela = args[:tabela].to_s
if Operacao.valida?(tabela)
puts "[#{Time.current}] Backfill da operação #{tabela}..."
executar.call { SincronizarSeriesAparelhoJob.perform_now(operacao: tabela) }
else
puts "[#{Time.current}] ERRO: operação inválida (#{tabela.inspect})."
puts "Válidas: #{Operacao.nomes_validos.join(', ')}"
end
end
desc 'Backfill por período — rake "simpli_route:series_periodo[2026-07-01,2026-07-31]"'
task :series_periodo, %i[de ate] => :environment do |_t, args|
executar.call { SincronizarSeriesAparelhoJob.perform_now(de: Date.parse(args[:de]), ate: Date.parse(args[:ate])) }
end
end

View File

@@ -0,0 +1,189 @@
require 'rails_helper'
RSpec.describe ConfiguracaoNotificacao do
# Preenchimento mínimo para os toggles passarem nas validações condicionais.
def config_smtp(extras = {})
described_class.new({
smtp_ativo: true, smtp_address: 'smtp.gmail.com', smtp_port: 587,
smtp_username: 'sistema@reem.com.br', smtp_password: 'segredo123'
}.merge(extras))
end
def config_whats(extras = {})
described_class.new({
whatsapp_ativo: true, twilio_account_sid: 'AC123',
twilio_auth_token: 'tok123', twilio_from: '+14155238886'
}.merge(extras))
end
describe '.instancia' do
it 'devolve sempre a mesma linha' do
primeira = described_class.instancia
expect(described_class.instancia.id).to eq(primeira.id)
expect(described_class.count).to eq(1)
end
end
describe '.normalizar_telefone' do
{
'11 920051157' => '+5511920051157',
'(11) 92005-1157' => '+5511920051157',
'011 92005-1157' => '+5511920051157',
'+55 11 92005-1157' => '+5511920051157',
'whatsapp:+5511920051157' => '+5511920051157',
'1132224444' => '+551132224444',
# DDD 55 (Santa Maria/RS): o "55" inicial é o DDD, não o país.
'55991234567' => '+5555991234567'
}.each do |entrada, esperado|
it "converte #{entrada.inspect} em #{esperado}" do
expect(described_class.normalizar_telefone(entrada)).to eq(esperado)
end
end
it 'devolve nil para vazio ou número curto demais' do
expect(described_class.normalizar_telefone(nil)).to be_nil
expect(described_class.normalizar_telefone('')).to be_nil
expect(described_class.normalizar_telefone('920051157')).to be_nil
end
end
describe '.canal' do
it 'prefixa whatsapp:' do
expect(described_class.canal('11 920051157')).to eq('whatsapp:+5511920051157')
end
it 'devolve nil quando o número é inválido' do
expect(described_class.canal('abc')).to be_nil
end
end
describe 'atributos cifrados' do
# A tabela é singleton (índice único em singleton_guard) e a migration já
# cria a linha — por isso os testes que persistem usam `.instancia`.
it 'grava criptografado e lê de volta em claro' do
cfg = described_class.instancia
cfg.update!(smtp_password: 'minha-senha', twilio_auth_token: 'meu-token')
cfg.reload
expect(cfg.smtp_password).to eq('minha-senha')
expect(cfg.twilio_auth_token).to eq('meu-token')
expect(cfg.smtp_password_cifrado).to be_present
expect(cfg.smtp_password_cifrado).not_to include('minha-senha')
end
it 'não marca o registro como alterado ao reatribuir o mesmo valor' do
cfg = described_class.instancia
cfg.update!(smtp_password: 'igual')
cfg.smtp_password = 'igual'
expect(cfg).not_to be_changed
end
it 'trata segredo ilegível como ausente, sem estourar' do
cfg = described_class.instancia
cfg.update!(smtp_password: 'x')
cfg.update_column(:smtp_password_cifrado, 'lixo-que-nao-decifra')
expect(cfg.reload.smtp_password).to be_nil
expect(cfg.smtp_password?).to be(false)
end
end
describe 'validações' do
it 'não exige SMTP enquanto o envio de e-mail está desligado' do
expect(described_class.new).to be_valid
end
it 'exige servidor, usuário e senha quando o e-mail é ativado' do
cfg = described_class.new(smtp_ativo: true)
expect(cfg).not_to be_valid
expect(cfg.errors.attribute_names).to include(:smtp_address, :smtp_username, :smtp_password)
end
it 'exige credenciais do Twilio quando o WhatsApp é ativado' do
cfg = described_class.new(whatsapp_ativo: true)
expect(cfg).not_to be_valid
expect(cfg.errors.attribute_names).to include(:twilio_account_sid, :twilio_auth_token, :twilio_from)
end
it 'rejeita e-mail administrativo malformado' do
expect(described_class.new(email_admin: 'sem-arroba')).not_to be_valid
end
it 'rejeita porta fora da faixa' do
expect(described_class.new(smtp_port: 0)).not_to be_valid
expect(described_class.new(smtp_port: 70_000)).not_to be_valid
end
it 'normaliza os telefones antes de salvar' do
cfg = described_class.instancia
cfg.update!(whatsapp_admin: '(11) 92005-1157')
expect(cfg.reload.whatsapp_admin).to eq('+5511920051157')
end
end
describe '#smtp_settings' do
it 'devolve nil quando não está pronto, para o .env continuar valendo' do
expect(described_class.new.smtp_settings).to be_nil
expect(config_smtp(smtp_ativo: false).smtp_settings).to be_nil
end
it 'usa STARTTLS na 587' do
settings = config_smtp.smtp_settings
expect(settings[:enable_starttls_auto]).to be(true)
expect(settings).not_to have_key(:ssl)
expect(settings[:password]).to eq('segredo123')
expect(settings[:authentication]).to eq(:plain)
end
it 'usa SSL direto na 465' do
settings = config_smtp(smtp_port: 465).smtp_settings
expect(settings[:ssl]).to be(true)
expect(settings).not_to have_key(:enable_starttls_auto)
end
it 'deriva o domínio do remetente quando não informado' do
expect(config_smtp.smtp_settings[:domain]).to eq('reem.com.br')
end
end
describe '#remetente_formatado' do
it 'monta "Nome" <email> quando há nome' do
cfg = config_smtp(remetente_email: 'nao-responda@reem.com.br', remetente_nome: 'Reem Transporte')
expect(cfg.remetente_formatado).to eq('"Reem Transporte" <nao-responda@reem.com.br>')
end
it 'cai no usuário do SMTP quando não há remetente próprio' do
expect(config_smtp.remetente_formatado).to eq('sistema@reem.com.br')
end
end
describe '#credenciais_whatsapp' do
it 'usa o banco quando está completo' do
creds = config_whats.credenciais_whatsapp
expect(creds[:sid]).to eq('AC123')
expect(creds[:from]).to eq('whatsapp:+14155238886')
end
it 'cai no .env quando o banco está incompleto' do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('TWILIO_ACCOUNT_SID').and_return('AC_env')
allow(ENV).to receive(:[]).with('TWILIO_AUTH_TOKEN').and_return('tok_env')
allow(ENV).to receive(:[]).with('TWILIO_WHATSAPP_FROM').and_return('+14155238886')
expect(described_class.new.credenciais_whatsapp[:sid]).to eq('AC_env')
end
it 'não mistura SID do banco com token do .env' do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('TWILIO_ACCOUNT_SID').and_return('AC_env')
allow(ENV).to receive(:[]).with('TWILIO_AUTH_TOKEN').and_return('tok_env')
allow(ENV).to receive(:[]).with('TWILIO_WHATSAPP_FROM').and_return('+14155238886')
# SID no banco mas sem token → whatsapp_pronto? é false → vai inteiro pro ENV
cfg = described_class.new(whatsapp_ativo: true, twilio_account_sid: 'AC_banco')
expect(cfg.credenciais_whatsapp[:sid]).to eq('AC_env')
expect(cfg.credenciais_whatsapp[:token]).to eq('tok_env')
end
end
end

137
spec/models/entrega_spec.rb Normal file
View File

@@ -0,0 +1,137 @@
require 'rails_helper'
# Trava o EIXO DE DATA do espelho de rastreio.
#
# O sistema tem dois eixos e eles NÃO são intercambiáveis:
# planned_date → dia planejado da visita (scope :no_periodo)
# checkout → dia REAL da entrega (scope :no_periodo_checkout)
#
# A elegibilidade da consolidação — o que vira dinheiro — é sempre pelo
# `checkout`. Um filtro que use `planned_date` some com entrega elegível na
# virada de mês e o fechamento não bate. Foi exatamente o bug do seletor de
# veículos da nova consolidação (Entrega.veiculos_de_motoristas usava
# :no_periodo), por isso o caso da virada está fixado aqui.
#
# A tabela do espelho é EXTERNA (sem migration, sem factory) — quem a cria e
# derruba no banco de teste é o harness em spec/support/espelho_rastreio.rb, que
# também expõe o helper `semear`.
RSpec.describe Entrega, type: :model do
include_context 'espelho de rastreio'
describe '.veiculos_de_motoristas' do
# Período de fechamento usado em todos os exemplos.
let(:inicio) { Date.new(2026, 8, 1) }
let(:fim) { Date.new(2026, 8, 14) }
it 'inclui o veículo de entrega PLANEJADA fora do período mas ENTREGUE dentro' do
# A regressão: planejada em 31/07, entregue em 01/08. É elegível (checkout
# dentro), logo o veículo TEM que aparecer — senão não há como marcá-lo no
# filtro e a entrega sai do fechamento sem aviso.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_001', status: 'completed',
planned_date: Time.zone.local(2026, 7, 31, 8, 0),
checkout: Time.zone.local(2026, 8, 1, 9, 30) }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to eq(['GADE_001'])
end
it 'exclui o veículo de entrega PLANEJADA dentro do período mas ENTREGUE fora' do
# O espelho do caso acima: planejada em 14/08, entregue em 20/08. Não é
# elegível neste fechamento, então o veículo não pode aparecer.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_002', status: 'completed',
planned_date: Time.zone.local(2026, 8, 14, 8, 0),
checkout: Time.zone.local(2026, 8, 20, 9, 30) }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to be_empty
end
it 'inclui o último dia do período inteiro (checkout às 23h de 14/08)' do
# no_periodo_checkout usa `< fim + 1`; um `<= fim` cortaria o último dia.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_003', status: 'completed',
planned_date: Time.zone.local(2026, 8, 14, 7, 0),
checkout: Time.zone.local(2026, 8, 14, 23, 40) }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to eq(['GADE_003'])
end
it 'inclui veículo de INSUCESSO (failed conta como atendida)' do
# Atendida = o motorista foi ao local, com sucesso OU insucesso. Insucesso
# é elegível e precisa ser classificado no wizard.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_004', status: 'failed',
planned_date: Time.zone.local(2026, 8, 5, 8, 0),
checkout: Time.zone.local(2026, 8, 5, 16, 0) }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to eq(['GADE_004'])
end
it 'exclui veículo que só tem entrega PENDENTE (sem checkout) no período' do
# Pendente não é elegível; listar o veículo só sujaria o filtro com um
# carro de zero elegíveis.
semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_005', status: 'pending',
planned_date: Time.zone.local(2026, 8, 6, 8, 0), checkout: nil }])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to be_empty
end
it 'não vaza veículo de outro motorista' do
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_006', status: 'completed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) },
{ driver: 'MARIA SOUZA', vehicle: 'GADE_007', status: 'completed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 11, 0) }
])
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim))
.to eq(['GADE_006'])
end
# A invariante que realmente protege o financeiro: o seletor de veículos do
# formulário e o conjunto elegível da consolidação têm que enxergar o MESMO
# recorte. Se alguém mexer no eixo de data de um dos dois, este exemplo cai.
it 'lista exatamente os veículos que têm entrega elegível no período' do
semear([
# elegíveis (checkout dentro)
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_010', status: 'completed',
planned_date: Time.zone.local(2026, 7, 30, 8, 0), checkout: Time.zone.local(2026, 8, 2, 10, 0) },
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_011', status: 'failed',
planned_date: Time.zone.local(2026, 8, 9, 8, 0), checkout: Time.zone.local(2026, 8, 9, 10, 0) },
# não elegíveis
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_012', status: 'completed',
planned_date: Time.zone.local(2026, 8, 10, 8, 0), checkout: Time.zone.local(2026, 9, 1, 10, 0) },
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_013', status: 'pending',
planned_date: Time.zone.local(2026, 8, 11, 8, 0), checkout: nil }
])
elegiveis = described_class.atendidas.da_conta_gade
.no_periodo_checkout(inicio, fim)
.do_motorista('JOSE DA SILVA')
.distinct.pluck(:vehicle).compact.sort
expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim).sort)
.to eq(elegiveis)
expect(elegiveis).to eq(%w[GADE_010 GADE_011])
end
it 'devolve vazio sem motorista informado (não toca o banco)' do
expect(described_class.veiculos_de_motoristas([], inicio: inicio, fim: fim)).to eq([])
expect(described_class.veiculos_de_motoristas(nil, inicio: inicio, fim: fim)).to eq([])
end
end
# Os dois scopes são a origem da confusão — vale fixar que cada um olha para a
# coluna que promete, para nenhum refactor trocar um pelo outro em silêncio.
describe 'eixos de período' do
it ':no_periodo filtra por planned_date e :no_periodo_checkout por checkout' do
expect(described_class.no_periodo(Date.new(2026, 8, 1), Date.new(2026, 8, 14)).to_sql)
.to include('planned_date')
sql_checkout = described_class.no_periodo_checkout(Date.new(2026, 8, 1), Date.new(2026, 8, 14)).to_sql
expect(sql_checkout).to include('checkout')
expect(sql_checkout).not_to include('planned_date')
end
end
end

View File

@@ -8,7 +8,8 @@ RSpec.describe 'Nomes de tabela dos models' do
Consolidacao => 'consolidacoes',
ConsolidacaoMotorista => 'consolidacao_motoristas',
ConsolidacaoEntrega => 'consolidacao_entregas',
Configuracao => 'configuracoes'
Configuracao => 'configuracoes',
ConfiguracaoNotificacao => 'configuracao_notificacoes'
}.each do |model, tabela|
it "#{model} aponta para #{tabela}" do
expect(model.table_name).to eq(tabela)

View File

@@ -0,0 +1,15 @@
require 'rails_helper'
RSpec.describe ConfiguracaoNotificacaoPolicy do
subject { described_class }
# Mais restrito que ConfiguracaoPolicy de propósito: aqui ficam a senha do
# e-mail e o token do Twilio, então gerente não entra.
permissions :show?, :update? do
it 'libera só para admin' do
expect(subject).to permit(build(:admin), ConfiguracaoNotificacao)
expect(subject).not_to permit(build(:gerente), ConfiguracaoNotificacao)
expect(subject).not_to permit(build(:operador), ConfiguracaoNotificacao)
end
end
end

View File

@@ -0,0 +1,134 @@
require 'rails_helper'
RSpec.describe 'Admin::ConfiguracaoNotificacoes', type: :request do
let(:config) { ConfiguracaoNotificacao.instancia }
def patch_config(atributos, acao: 'salvar')
patch admin_configuracao_notificacao_path,
params: { acao: acao, configuracao_notificacao: atributos }
end
describe 'permissão' do
it 'exige login' do
get admin_configuracao_notificacao_path
expect(response).to redirect_to(new_user_session_path)
end
it 'bloqueia gerente (só admin mexe em credenciais de envio)' do
sign_in create(:gerente)
get admin_configuracao_notificacao_path
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to match(/permissão/i)
end
it 'bloqueia gerente também no update' do
sign_in create(:gerente)
patch_config(smtp_address: 'smtp.invasor.com')
expect(response).to redirect_to(root_path)
expect(config.reload.smtp_address).not_to eq('smtp.invasor.com')
end
it 'libera admin' do
sign_in create(:admin)
get admin_configuracao_notificacao_path
expect(response).to have_http_status(:ok)
end
end
context 'como admin' do
let(:admin) { create(:admin) }
before { sign_in admin }
it 'salva as configurações' do
patch_config(smtp_address: 'smtp.gmail.com', smtp_port: 465,
email_admin: 'Chefe@Reem.com.br', whatsapp_admin: '(11) 92005-1157')
expect(response).to redirect_to(admin_configuracao_notificacao_path)
config.reload
expect(config.smtp_address).to eq('smtp.gmail.com')
expect(config.smtp_port).to eq(465)
expect(config.email_admin).to eq('chefe@reem.com.br')
expect(config.whatsapp_admin).to eq('+5511920051157')
end
it 'mantém a senha gravada quando o campo vem em branco' do
config.update!(smtp_password: 'senha-original')
patch_config(smtp_address: 'smtp.outro.com', smtp_password: '')
expect(config.reload.smtp_password).to eq('senha-original')
end
it 'substitui a senha quando o campo é preenchido' do
config.update!(smtp_password: 'senha-original')
patch_config(smtp_password: 'senha-nova')
expect(config.reload.smtp_password).to eq('senha-nova')
end
it 'reexibe o formulário quando ativa o e-mail sem credenciais' do
patch_config(smtp_ativo: '1', smtp_address: '', smtp_username: '')
expect(response).to have_http_status(:unprocessable_entity)
expect(config.reload.smtp_ativo).to be(false)
end
it 'registra auditoria sem vazar a senha nem o token' do
patch_config(smtp_address: 'smtp.gmail.com', smtp_password: 'super-secreta',
twilio_auth_token: 'token-secreto')
log = AuditoriaLog.where(entidade: 'ConfiguracaoNotificacao').last
expect(log.acao).to eq('editar_notificacoes')
expect(log.user_id).to eq(admin.id)
serializado = [log.dados_anteriores, log.dados_novos].to_json
expect(serializado).not_to include('super-secreta')
expect(serializado).not_to include('token-secreto')
expect(log.dados_novos['smtp_password_definida']).to be(true)
end
describe 'botões de teste' do
it 'avisa quando o e-mail administrativo não foi preenchido' do
patch_config({ email_admin: '' }, acao: 'testar_email')
expect(response).to redirect_to(admin_configuracao_notificacao_path)
expect(flash[:alert]).to match(/E-mail do administrador/i)
end
it 'avisa quando o SMTP não está pronto' do
patch_config({ email_admin: 'chefe@reem.com.br' }, acao: 'testar_email')
expect(flash[:alert]).to match(/Ativar envio de e-mail/i)
end
it 'envia o e-mail de teste quando o SMTP está configurado' do
resultado = Notificacao::Resultado.sucesso('E-mail de teste enviado.')
expect(Notificacao::TesteEmail).to receive(:new).and_return(double(executar: resultado))
patch_config({ email_admin: 'chefe@reem.com.br' }, acao: 'testar_email')
expect(flash[:notice]).to eq('E-mail de teste enviado.')
end
it 'avisa quando o WhatsApp administrativo não foi preenchido' do
patch_config({ whatsapp_admin: '' }, acao: 'testar_whatsapp')
expect(flash[:alert]).to match(/WhatsApp do administrador/i)
end
it 'reporta o erro traduzido do Twilio no flash' do
resultado = Notificacao::Resultado.falha('Sandbox exige opt-in.', '[63015] detalhe')
expect(Notificacao::TesteWhatsapp).to receive(:new).and_return(double(executar: resultado))
patch_config({ whatsapp_admin: '11 920051157' }, acao: 'testar_whatsapp')
expect(flash[:alert]).to include('Sandbox exige opt-in.')
expect(flash[:alert]).to include('63015')
end
end
end
end

View File

@@ -12,3 +12,209 @@ RSpec.describe 'Dashboard', type: :request do
expect(response).to redirect_to(motorista_dashboard_path)
end
end
# Trava a CONTABILIZAÇÃO DAS FALHAS no dashboard principal.
#
# A base financeira do dashboard é `atendidas` (concluídas + falhadas, com
# checkout) — o MESMO conjunto que a consolidação considera elegível e paga.
# Antes usava só `pagas` (concluídas) e o valor da tela ficava menor que o do
# fechamento; e as falhas eram filtradas por `planned_date`, caindo no período
# errado. Os dois casos estão fixados aqui.
#
# ⚠️ Falhada TEM checkout: o motorista foi ao local e fechou a visita com motivo
# de insucesso. É o que torna `checkout` o eixo correto também para elas.
RSpec.describe 'Dashboard — contabilização de falhas', type: :request do
include_context 'espelho de rastreio'
# Preço redondo para os valores esperados serem óbvios na leitura do spec.
let(:preco) { 10.0 }
let(:inicio) { '2026-08-01' }
let(:fim) { '2026-08-14' }
before do
create(:configuracao, chave: 'preco_entrega', valor: preco.to_s)
sign_in create(:gerente)
end
def abrir_dashboard
get dashboard_path, params: { inicio: inicio, fim: fim }
expect(response).to have_http_status(:ok)
response.body
end
it 'soma a falhada ao valor estimado e ao total de atendidas' do
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_001', status: 'completed',
planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) },
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_001', status: 'failed',
planned_date: Time.zone.local(2026, 8, 6, 8, 0), checkout: Time.zone.local(2026, 8, 6, 11, 0) }
])
corpo = abrir_dashboard
# 2 atendidas × R$ 10,00 — com a base antiga (`pagas`) seria 1 e R$ 10,00.
expect(corpo).to include('2 entregas atendidas')
expect(corpo).to include('1 concluídas · 1 falhadas')
expect(corpo).to include('R$ 20,00')
end
it 'conta a falhada pela data do CHECKOUT, não pela planejada' do
# Planejada em 31/07 (fora), entregue em 01/08 (dentro) → entra.
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_002', status: 'failed',
planned_date: Time.zone.local(2026, 7, 31, 8, 0), checkout: Time.zone.local(2026, 8, 1, 9, 0) }
])
corpo = abrir_dashboard
expect(corpo).to include('1 entregas atendidas')
expect(corpo).to include('0 concluídas · 1 falhadas')
end
it 'exclui a falhada planejada dentro do período mas com checkout fora' do
# O espelho do caso acima: planejada em 10/08 (dentro), entregue em 01/09
# (fora) → não entra. Com o eixo antigo (planned_date) entrava.
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_003', status: 'failed',
planned_date: Time.zone.local(2026, 8, 10, 8, 0), checkout: Time.zone.local(2026, 9, 1, 9, 0) }
])
corpo = abrir_dashboard
expect(corpo).to include('0 entregas atendidas')
expect(corpo).to include('0 concluídas · 0 falhadas')
end
it 'mantém a pendente fora do valor, contando-a só como pendente' do
# Pendente não tem checkout: continua no eixo planned_date e não vira R$.
semear([
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_004', status: 'pending',
planned_date: Time.zone.local(2026, 8, 7, 8, 0), checkout: nil }
])
corpo = abrir_dashboard
expect(corpo).to include('0 entregas atendidas')
expect(corpo).to include('1 pendentes')
end
it 'inclui a falhada no ranking por motorista' do
semear([
# JOSE: 1 concluída + 1 falhada = 2 atendidas → R$ 20,00
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_005', status: 'completed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) },
{ driver: 'JOSE DA SILVA', vehicle: 'GADE_005', status: 'failed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 15, 0) },
# MARIA: 1 concluída = 1 atendida → R$ 10,00
{ driver: 'MARIA SOUZA', vehicle: 'GADE_006', status: 'completed',
planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) }
])
corpo = abrir_dashboard
expect(corpo).to include('3 entregas atendidas')
expect(corpo).to include('JOSE DA SILVA')
# R$ 20,00 do JOSE só existe se a falhada dele entrou na conta do ranking
# (com a base antiga ele apareceria com R$ 10,00, igual à MARIA).
expect(corpo).to include('R$ 20,00')
expect(corpo).to include('R$ 30,00') # total estimado do período
end
end
# Trava a ABA "CONSOLIDADO" do card de motoristas.
#
# O card tem duas abas sobre o mesmo ranking e elas mostram números diferentes de
# propósito: "Estimado" = atendidas × preço; "Consolidado" = o que realmente foi
# fechado (com bônus/desconto/retirada) + a quantidade de entregas do fechamento.
# Confundir as duas é justamente o que este spec impede.
RSpec.describe 'Dashboard — aba Consolidado do ranking de motoristas', type: :request do
include_context 'espelho de rastreio'
let(:inicio) { '2026-08-01' }
let(:fim) { '2026-08-14' }
before do
create(:configuracao, chave: 'preco_entrega', valor: '10.0')
sign_in create(:gerente)
end
# Só o conteúdo da aba consolidada: a aba estimada renderiza o MESMO markup
# (moeda + "N entregas"), então asserção no body inteiro passaria por acidente.
def painel_consolidado
get dashboard_path, params: { inicio: inicio, fim: fim }
expect(response).to have_http_status(:ok)
Nokogiri::HTML(response.body).at_css('#ranking-painel-consolidado').text
end
# cria as entregas ANTES do motorista: ConsolidacaoMotorista#recalcular_valor
# (before_save) soma as consolidacao_entregas do mesmo nome — sem elas o
# valor_total nasceria zerado.
def consolidar!(nome, linhas, consolidacao)
linhas.each { |l| create(:consolidacao_entrega, { consolidacao: consolidacao, motorista_nome: nome }.merge(l)) }
create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: nome)
end
let(:consolidacao) do
create(:consolidacao, status: :finalizada,
data_inicio: Date.new(2026, 8, 1), data_fim: Date.new(2026, 8, 14))
end
it 'mostra o valor fechado e a quantidade exata de entregas de cada motorista' do
# JOSE: 2 entregas (TRK-A e TRK-B). TRK-A tem DOIS pilares (normal + bônus),
# então são 3 linhas para 2 entregas — é o caso que quebra se alguém contar
# linhas em vez de tracking_id distinto.
consolidar!('JOSE DA SILVA', [
{ tracking_id: 'TRK-A', tipo: :entrega_normal, valor_aplicado: 10.0 },
{ tracking_id: 'TRK-A', tipo: :bonus, valor_aplicado: 5.0 },
{ tracking_id: 'TRK-B', tipo: :entrega_normal, valor_aplicado: 10.0 }
], consolidacao)
consolidar!('MARIA SOUZA', [
{ tracking_id: 'TRK-C', tipo: :entrega_normal, valor_aplicado: 10.0 }
], consolidacao)
texto = painel_consolidado
expect(texto).to include('JOSE DA SILVA')
expect(texto).to include('R$ 25,00') # 10 + 5 + 10, valor REAL do fechamento
expect(texto).to include('2 entregas') # e NÃO 3: TRK-A conta uma vez só
expect(texto).to include('MARIA SOUZA')
expect(texto).to include('R$ 10,00')
expect(texto).to include('1 entregas')
end
it 'subtrai o desconto do valor fechado' do
consolidar!('JOSE DA SILVA', [
{ tracking_id: 'TRK-A', tipo: :entrega_normal, valor_aplicado: 10.0 },
{ tracking_id: 'TRK-A', tipo: :desconto, valor_aplicado: 4.0 }
], consolidacao)
expect(painel_consolidado).to include('R$ 6,00')
end
it 'não lista motorista arquivado, que saiu do fechamento' do
consolidar!('JOSE DA SILVA', [
{ tracking_id: 'TRK-A', tipo: :entrega_normal, valor_aplicado: 10.0 }
], consolidacao)
cm = consolidar!('MARIA SOUZA', [
{ tracking_id: 'TRK-C', tipo: :entrega_normal, valor_aplicado: 10.0 }
], consolidacao)
cm.arquivar!(nil)
texto = painel_consolidado
expect(texto).to include('JOSE DA SILVA')
expect(texto).not_to include('MARIA SOUZA')
end
it 'ignora consolidação em rascunho — só entra o que foi finalizado' do
rascunho = create(:consolidacao, status: :rascunho,
data_inicio: Date.new(2026, 8, 1), data_fim: Date.new(2026, 8, 14))
consolidar!('JOSE DA SILVA', [
{ tracking_id: 'TRK-A', tipo: :entrega_normal, valor_aplicado: 10.0 }
], rascunho)
expect(painel_consolidado).to include('Nenhuma consolidação finalizada neste período')
end
end

View File

@@ -0,0 +1,107 @@
require 'rails_helper'
# Trava a CONTABILIZAÇÃO DAS ENTREGAS SEM SUCESSO no painel do MOTORISTA.
#
# O painel do motorista ficou para trás quando o dashboard administrativo passou
# a usar `atendidas` (ver spec/requests/dashboard_spec.rb): continuava em `pagas`
# (só concluídas) e no eixo `planned_date`. O motorista via um valor MENOR do que
# ia receber — e a diferença só aparecia no fechamento, sem explicação.
#
# ⚠️ Sem sucesso TEM checkout: o motorista foi ao local e fechou a visita com
# motivo de insucesso. O deslocamento teve custo, a empresa paga, e o checkout é
# o eixo de data correto também para essas.
RSpec.describe 'Painel do motorista — contabilização das entregas sem sucesso', type: :request do
include ActiveSupport::Testing::TimeHelpers
include_context 'espelho de rastreio'
# O painel não aceita filtro de período (é sempre "do dia 1º até hoje"), então
# congelamos a data: sem isso o spec quebraria sozinho ao rodar no dia 1º, com
# as datas semeadas caindo fora do mês corrente.
let(:hoje) { Date.new(2026, 8, 20) }
let(:preco) { 10.0 }
let(:nome) { 'JOSE DA SILVA' }
before do
travel_to(hoje)
create(:configuracao, chave: 'preco_entrega', valor: preco.to_s)
sign_in create(:motorista, nome: nome)
end
after { travel_back }
def abrir_painel
get motorista_dashboard_path
expect(response).to have_http_status(:ok)
response.body
end
it 'soma a entrega sem sucesso ao valor estimado' do
semear([
{ driver: nome, vehicle: 'GADE_001', status: 'completed',
planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) },
{ driver: nome, vehicle: 'GADE_001', status: 'failed',
planned_date: Time.zone.local(2026, 8, 6, 8, 0), checkout: Time.zone.local(2026, 8, 6, 11, 0) }
])
corpo = abrir_painel
# 2 atendidas × R$ 10,00 — com a base antiga (`pagas`) seria 1 e R$ 10,00.
expect(corpo).to include('2 entregas atendidas')
expect(corpo).to include('1 entregues · 1 sem sucesso')
expect(corpo).to include('R$ 20,00')
end
it 'conta a sem sucesso pela data do CHECKOUT, não pela planejada' do
# Planejada em 31/07 (fora do mês), fechada em 01/08 (dentro) → entra.
semear([
{ driver: nome, vehicle: 'GADE_002', status: 'failed',
planned_date: Time.zone.local(2026, 7, 31, 8, 0), checkout: Time.zone.local(2026, 8, 1, 9, 0) }
])
corpo = abrir_painel
expect(corpo).to include('1 entregas atendidas')
expect(corpo).to include('0 entregues · 1 sem sucesso')
end
it 'exclui a sem sucesso planejada no mês mas com checkout fora' do
# O espelho do caso acima: planejada em 10/08 (dentro), fechada em 01/09
# (fora) → não entra. Com o eixo antigo (planned_date) entrava.
semear([
{ driver: nome, vehicle: 'GADE_003', status: 'failed',
planned_date: Time.zone.local(2026, 8, 10, 8, 0), checkout: Time.zone.local(2026, 9, 1, 9, 0) }
])
corpo = abrir_painel
expect(corpo).to include('0 entregas atendidas')
expect(corpo).to include('0 entregues · 0 sem sucesso')
end
it 'não conta a entrega pendente, que ainda não tem checkout' do
semear([
{ driver: nome, vehicle: 'GADE_004', status: 'pending',
planned_date: Time.zone.local(2026, 8, 7, 8, 0), checkout: nil }
])
corpo = abrir_painel
expect(corpo).to include('0 entregas atendidas')
expect(corpo).to include('0 entregues · 0 sem sucesso')
end
it 'não mistura a entrega sem sucesso de outro motorista' do
semear([
{ driver: nome, vehicle: 'GADE_005', status: 'completed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) },
{ driver: 'MARIA SOUZA', vehicle: 'GADE_006', status: 'failed',
planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 15, 0) }
])
corpo = abrir_painel
expect(corpo).to include('1 entregas atendidas')
expect(corpo).to include('1 entregues · 0 sem sucesso')
expect(corpo).to include('R$ 10,00')
end
end

View File

@@ -0,0 +1,102 @@
# Harness da tabela do espelho de rastreio (db_reem_simplerout_2026) para os
# specs.
#
# A tabela é EXTERNA: não tem migration (o model Entrega proíbe explicitamente)
# e não tem factory. Sem este harness, qualquer teste que dependa dela só
# poderia mockar o método sob teste — o que não pega regressão de SQL, que é
# justamente onde moram os bugs de eixo de data (planned_date × checkout).
#
# Uso:
#
# RSpec.describe Entrega do
# include_context 'espelho de rastreio'
#
# it '...' do
# semear([{ driver: 'FULANO', vehicle: 'GADE_001', status: 'completed',
# planned_date: ..., checkout: ... }])
# end
# end
module EspelhoRastreio
TABELA = 'db_reem_simplerout_2026'.freeze
# `Entrega.da_conta_gade` filtra por DB_EXISTING_ACCOUNT_ID (default '95907').
# Gravamos nas linhas o primeiro valor configurado: se estiver 'all' o scope
# não filtra e o valor é indiferente; se estiver '95907' as linhas casam.
# Assim o harness não depende do ENV da máquina nem precisa stubar ENV.
CONTA = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').split(',').first.to_s.strip.presence || '95907'
class << self
def criar!
# ⚠️ GUARDA OBRIGATÓRIA. O model do espelho proíbe DROP porque a tabela
# real é a base de rastreio da operação. Aqui só criamos uma tabela de
# mesmo nome no banco de TESTE; se ela já existir COM DADOS, estamos
# apontados para dados de verdade e o spec tem que abortar em vez de
# destruí-los.
raise "Espelho de teste exige RAILS_ENV=test (está em #{Rails.env})" unless Rails.env.test?
return if @criada
if conexao.table_exists?(TABELA)
linhas = conexao.select_value("SELECT COUNT(*) FROM #{nome_citado}").to_i
if linhas.positive?
raise "#{TABELA} já existe no banco de teste com #{linhas} linha(s) — " \
'o spec NÃO vai derrubá-la. Aponte RAILS_ENV=test para um banco descartável.'
end
conexao.drop_table(TABELA)
end
conexao.create_table(TABELA, id: false) do |t|
t.string :tracking_id, null: false
t.string :account_id
t.bigint :reference_id
t.string :driver
t.string :vehicle
t.string :status
t.datetime :planned_date
t.datetime :checkout
t.string :contact_name
t.string :address
end
conexao.execute("ALTER TABLE #{nome_citado} ADD PRIMARY KEY (tracking_id)")
@criada = true
end
# Só derruba o que este harness criou.
def derrubar!
return unless @criada
conexao.drop_table(TABELA, if_exists: true)
@criada = false
end
def limpar!
conexao.execute("DELETE FROM #{nome_citado}") if @criada
end
private
def conexao
ActiveRecord::Base.connection
end
def nome_citado
conexao.quote_table_name(TABELA)
end
end
end
RSpec.shared_context 'espelho de rastreio' do
before(:all) { EspelhoRastreio.criar! }
after(:all) { EspelhoRastreio.derrubar! }
before { EspelhoRastreio.limpar! }
# O model é readonly? = true, então insert_all (que não instancia registro) é
# a única via de gravação — e vale só para o banco de teste.
def semear(linhas)
Entrega.insert_all(
linhas.map.with_index do |l, i|
{ tracking_id: "TRK-#{i}-#{SecureRandom.hex(3)}", account_id: EspelhoRastreio::CONTA }.merge(l)
end
)
end
end