Compare commits
8 Commits
dd5621a215
...
18239b133b
| Author | SHA256 | Date | |
|---|---|---|---|
| 18239b133b | |||
| 7b3ec3e407 | |||
| e9db6b7164 | |||
| 4965931c13 | |||
| cebd6e2dcd | |||
| e3d4546c3c | |||
| c86bae66a4 | |||
| e530815377 |
18
.env.example
18
.env.example
@@ -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.
|
# deixe vazio/false para não forçar https em localhost.
|
||||||
FORCE_SSL=true
|
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 — WhatsApp (opcional, Fase 8) ─────────────────────
|
||||||
TWILIO_ACCOUNT_SID=
|
TWILIO_ACCOUNT_SID=
|
||||||
TWILIO_AUTH_TOKEN=
|
TWILIO_AUTH_TOKEN=
|
||||||
@@ -57,6 +66,15 @@ SMTP_USERNAME=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_DOMAIN=gade.com.br
|
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 ───────────────────────────────────────────────────────
|
||||||
APP_HOST=localhost:3000
|
APP_HOST=localhost:3000
|
||||||
APP_NAME=Reem Logística
|
APP_NAME=Reem Logística
|
||||||
|
|||||||
327
README.md
327
README.md
@@ -1786,3 +1786,330 @@ app/views/admin/configuracoes/index.html.erb # idem (arquivo distint
|
|||||||
|
|
||||||
</details>
|
</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 (20–21/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 01–14/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>
|
||||||
|
|||||||
101
app/controllers/admin/configuracao_notificacoes_controller.rb
Normal file
101
app/controllers/admin/configuracao_notificacoes_controller.rb
Normal 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
|
||||||
@@ -76,35 +76,43 @@ class DashboardController < ApplicationController
|
|||||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||||
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
|
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
|
||||||
|
|
||||||
# PAGAS pela DATA REAL (checkout) — base de toda a análise financeira por data.
|
# ATENDIDAS pela DATA REAL (checkout) — base de toda a análise financeira.
|
||||||
pagas = Entrega.da_conta_gade.pagas.no_periodo_checkout(@periodo_inicio, @periodo_fim)
|
#
|
||||||
pagas = pagas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
# 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 = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim)
|
||||||
pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||||||
|
|
||||||
# FALHADAS (insucesso) pela data planejada — também não têm checkout.
|
# Quebra do total entre sucesso e insucesso (ambos já dentro de `atendidas`).
|
||||||
falhadas = Entrega.da_conta_gade.falhadas.no_periodo(@periodo_inicio, @periodo_fim)
|
# ⚠️ Falhada TEM checkout: o motorista foi ao local e fechou a visita com
|
||||||
falhadas = falhadas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
# 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.
|
||||||
# Totais gerais
|
@entregas_pagas = atendidas.concluidas.count
|
||||||
@entregas_pagas = pagas.count
|
@entregas_falhadas = atendidas.falhadas.count
|
||||||
|
@entregas_atendidas = @entregas_pagas + @entregas_falhadas
|
||||||
@entregas_pendentes = pendentes.count
|
@entregas_pendentes = pendentes.count
|
||||||
@entregas_falhadas = falhadas.count
|
@total_entregas = @entregas_atendidas + @entregas_pendentes
|
||||||
@total_entregas = @entregas_pagas + @entregas_pendentes + @entregas_falhadas
|
|
||||||
|
|
||||||
# Configurações de preço
|
# Configurações de preço
|
||||||
config = Configuracao.mapa_de_precos
|
config = Configuracao.mapa_de_precos
|
||||||
|
|
||||||
# Valor estimado total
|
# Valor estimado total
|
||||||
@valor_estimado = (@entregas_pagas * config[:entrega]).round(2)
|
@valor_estimado = (@entregas_atendidas * config[:entrega]).round(2)
|
||||||
|
|
||||||
# Por motorista (todos)
|
# Por motorista (todos)
|
||||||
@motoristas = pagas.group(:driver)
|
@motoristas = atendidas.group(:driver)
|
||||||
.count
|
.count
|
||||||
.sort_by { |_, v| -v }
|
.sort_by { |_, v| -v }
|
||||||
.map do |driver, qtd|
|
.map do |driver, qtd|
|
||||||
{
|
{
|
||||||
nome: driver,
|
nome: driver,
|
||||||
entregas: qtd,
|
entregas: qtd,
|
||||||
@@ -113,14 +121,14 @@ class DashboardController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Por local de entrega (contact_name)
|
# Por local de entrega (contact_name)
|
||||||
@por_operacao = pagas.group(:contact_name)
|
@por_operacao = atendidas.group(:contact_name)
|
||||||
.count
|
.count
|
||||||
.sort_by { |_, v| -v }
|
.sort_by { |_, v| -v }
|
||||||
.first(6)
|
.first(6)
|
||||||
.to_h
|
.to_h
|
||||||
|
|
||||||
# Evolução diária no período (para Chart.js) — por data real (checkout)
|
# 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
|
# Consolidações cujo período (data_inicio..data_fim) cruza o período do dashboard
|
||||||
# — independe de quando foram criadas (created_at).
|
# — independe de quando foram criadas (created_at).
|
||||||
@@ -219,22 +227,39 @@ class DashboardController < ApplicationController
|
|||||||
# Custo por motorista (todos)
|
# Custo por motorista (todos)
|
||||||
@fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total)
|
@fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total)
|
||||||
.sort_by { |_, v| -v }
|
.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
|
end
|
||||||
|
|
||||||
# `pagas` já vem filtrado por checkout no período. Agrupa pela DATA REAL
|
# `atendidas` já vem filtrado por checkout no período (concluídas + falhadas).
|
||||||
# (DATE(checkout)) — DATE() sobre timestamp não faz conversão de fuso.
|
# Agrupa pela DATA REAL (DATE(checkout)) — DATE() sobre timestamp não faz
|
||||||
def build_grafico_diario(pagas, preco_entrega)
|
# conversão de fuso.
|
||||||
|
def build_grafico_diario(atendidas, preco_entrega)
|
||||||
dias = (@periodo_inicio..@periodo_fim).to_a
|
dias = (@periodo_inicio..@periodo_fim).to_a
|
||||||
|
|
||||||
if dias.size <= MAX_DIAS_GRAFICO
|
if dias.size <= MAX_DIAS_GRAFICO
|
||||||
# Granularidade diária (períodos curtos)
|
# 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') }
|
labels = dias.map { |d| d.strftime('%d/%m') }
|
||||||
valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) }
|
valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) }
|
||||||
qtds = dias.map { |d| contagem[d.to_s] || 0 }
|
qtds = dias.map { |d| contagem[d.to_s] || 0 }
|
||||||
else
|
else
|
||||||
# Período longo: agrega por mês para o gráfico não ficar vazio nem poluído.
|
# 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
|
meses = meses_no_periodo
|
||||||
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
|
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
|
||||||
valores = meses.map { |m| ((contagem[m] || 0) * preco_entrega).round(2) }
|
valores = meses.map { |m| ((contagem[m] || 0) * preco_entrega).round(2) }
|
||||||
|
|||||||
@@ -7,15 +7,27 @@ module Motorista
|
|||||||
def index
|
def index
|
||||||
nome = current_user.nome
|
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_inicio = Date.current.beginning_of_month
|
||||||
@estimado_fim = Date.current
|
@estimado_fim = Date.current
|
||||||
entregas_mes = Entrega.pagas.da_conta_gade
|
atendidas = Entrega.atendidas.da_conta_gade
|
||||||
.do_motorista(nome)
|
.do_motorista(nome)
|
||||||
.no_periodo(@estimado_inicio, @estimado_fim)
|
.no_periodo_checkout(@estimado_inicio, @estimado_fim)
|
||||||
.count
|
@entregas_concluidas = atendidas.concluidas.count
|
||||||
@valor_estimado = entregas_mes * Configuracao.preco_entrega
|
@entregas_falhadas = atendidas.falhadas.count
|
||||||
@entregas_mes = entregas_mes
|
@entregas_mes = @entregas_concluidas + @entregas_falhadas
|
||||||
|
@valor_estimado = @entregas_mes * Configuracao.preco_entrega
|
||||||
|
|
||||||
# Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas
|
# Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas
|
||||||
@minhas_consolidacoes = Consolidacao.ativas
|
@minhas_consolidacoes = Consolidacao.ativas
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# app/mailers/application_mailer.rb
|
# 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
|
class ApplicationMailer < ActionMailer::Base
|
||||||
default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br')
|
|
||||||
layout 'mailer'
|
layout 'mailer'
|
||||||
|
|
||||||
|
default from: proc { ConfiguracaoNotificacao.remetente },
|
||||||
|
delivery_method_options: proc { ConfiguracaoNotificacao.opcoes_de_entrega }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# app/mailers/consolidacao_mailer.rb
|
# app/mailers/consolidacao_mailer.rb
|
||||||
class ConsolidacaoMailer < ApplicationMailer
|
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)
|
def pagamento_fechado(user, consolidacao, consolidacao_motorista)
|
||||||
@user = user
|
@user = user
|
||||||
|
|||||||
14
app/mailers/teste_mailer.rb
Normal file
14
app/mailers/teste_mailer.rb
Normal 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
|
||||||
71
app/models/concerns/atributo_cifrado.rb
Normal file
71
app/models/concerns/atributo_cifrado.rb
Normal 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
|
||||||
@@ -11,6 +11,10 @@ class Configuracao < ApplicationRecord
|
|||||||
preco_extraordinaria
|
preco_extraordinaria
|
||||||
preco_termo
|
preco_termo
|
||||||
preco_termo_especial
|
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_whatsapp
|
||||||
notificacao_email
|
notificacao_email
|
||||||
empresa_nome
|
empresa_nome
|
||||||
|
|||||||
192
app/models/configuracao_notificacao.rb
Normal file
192
app/models/configuracao_notificacao.rb
Normal 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
|
||||||
@@ -121,12 +121,20 @@ class Entrega < ApplicationRecord
|
|||||||
|
|
||||||
# Veículos usados pelo(s) motorista(s) selecionado(s) no período — alimenta o
|
# 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.
|
# 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/08–14/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)
|
def self.veiculos_de_motoristas(motoristas, inicio: nil, fim: nil)
|
||||||
nomes = Array(motoristas).reject(&:blank?)
|
nomes = Array(motoristas).reject(&:blank?)
|
||||||
return [] if nomes.empty?
|
return [] if nomes.empty?
|
||||||
|
|
||||||
base = da_conta_gade.where(driver: nomes)
|
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?)
|
base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
22
app/policies/configuracao_notificacao_policy.rb
Normal file
22
app/policies/configuracao_notificacao_policy.rb
Normal 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
|
||||||
26
app/services/notificacao/cliente_twilio.rb
Normal file
26
app/services/notificacao/cliente_twilio.rb
Normal 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
|
||||||
27
app/services/notificacao/resultado.rb
Normal file
27
app/services/notificacao/resultado.rb
Normal 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
|
||||||
69
app/services/notificacao/teste_email.rb
Normal file
69
app/services/notificacao/teste_email.rb
Normal 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
|
||||||
74
app/services/notificacao/teste_whatsapp.rb
Normal file
74
app/services/notificacao/teste_whatsapp.rb
Normal 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
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
# app/services/notificacao_service.rb
|
# app/services/notificacao_service.rb
|
||||||
#
|
#
|
||||||
# Envia notificações aos motoristas quando uma consolidação é finalizada.
|
# Envia notificações aos motoristas quando uma consolidação é finalizada ou
|
||||||
# Canais controlados pelas configurações: notificacao_whatsapp / notificacao_email.
|
# quando o pagamento é efetuado.
|
||||||
# Falhas de envio NUNCA quebram o fluxo de finalização (apenas logam).
|
|
||||||
#
|
#
|
||||||
|
# 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
|
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)
|
def self.notificar_finalizacao(consolidacao)
|
||||||
new(consolidacao).notificar_todos
|
new(consolidacao).notificar_todos
|
||||||
end
|
end
|
||||||
@@ -16,106 +33,73 @@ class NotificacaoService
|
|||||||
|
|
||||||
def initialize(consolidacao)
|
def initialize(consolidacao)
|
||||||
@consolidacao = consolidacao
|
@consolidacao = consolidacao
|
||||||
|
@config = ConfiguracaoNotificacao.instancia
|
||||||
end
|
end
|
||||||
|
|
||||||
def notificar_todos
|
def notificar_todos
|
||||||
@consolidacao.consolidacao_motoristas.ativos.each do |cm|
|
@consolidacao.consolidacao_motoristas.ativos.each { |cm| notificar(cm, :fechado) }
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def enviar_aviso_pagamento(cm)
|
def enviar_aviso_pagamento(consolidacao_motorista)
|
||||||
user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
|
notificar(consolidacao_motorista, :pago)
|
||||||
return unless user
|
|
||||||
|
|
||||||
enviar_whatsapp_pagamento(user, cm) if whatsapp_ativo?
|
|
||||||
enviar_email_pagamento(user, cm) if email_ativo? && user.email.present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def whatsapp_ativo?
|
def notificar(cm, evento)
|
||||||
Configuracao.valor('notificacao_whatsapp') == 'true' &&
|
user = motorista_de(cm)
|
||||||
ENV['TWILIO_ACCOUNT_SID'].present?
|
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
|
end
|
||||||
|
|
||||||
def email_ativo?
|
def motorista_de(cm)
|
||||||
Configuracao.valor('notificacao_email') == 'true'
|
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
|
end
|
||||||
|
|
||||||
# Moeda BR com separador de milhar: R$ 1.234,56
|
# Moeda BR com separador de milhar: R$ 1.234,56
|
||||||
def moeda(v)
|
def moeda(valor)
|
||||||
inteiro, decimais = format('%.2f', v.to_f).split('.')
|
inteiro, decimais = format('%.2f', valor.to_f).split('.')
|
||||||
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||||
"R$ #{inteiro},#{decimais}"
|
"R$ #{inteiro},#{decimais}"
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
262
app/views/admin/configuracao_notificacoes/show.html.erb
Normal file
262
app/views/admin/configuracao_notificacoes/show.html.erb
Normal 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 <sua-palavra></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>
|
||||||
@@ -8,6 +8,26 @@
|
|||||||
|
|
||||||
<%= render 'shared/flash' %>
|
<%= 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 %>
|
<%# Pilares de preço %>
|
||||||
<div>
|
<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>
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Pilares de Preço</h2>
|
||||||
|
|||||||
45
app/views/dashboard/_ranking_motoristas.html.erb
Normal file
45
app/views/dashboard/_ranking_motoristas.html.erb
Normal 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 %>
|
||||||
@@ -134,8 +134,14 @@
|
|||||||
<p class="text-3xl lg:text-4xl font-black text-white mb-1 break-words">
|
<p class="text-3xl lg:text-4xl font-black text-white mb-1 break-words">
|
||||||
<%= moeda(@valor_estimado) %>
|
<%= moeda(@valor_estimado) %>
|
||||||
</p>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,42 +262,48 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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">
|
<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? %>
|
<%# Mesmas pílulas da paginação de pagamentos (as classes estão duplicadas
|
||||||
<div class="space-y-3">
|
no JS que troca a aba — mexeu aqui, mexa lá). %>
|
||||||
<% @motoristas.each_with_index do |m, i| %>
|
<div id="ranking-abas" class="flex gap-1.5">
|
||||||
<div class="flex items-center gap-3">
|
<button type="button" data-aba="estimado"
|
||||||
<span class="text-xs font-bold w-5 text-center
|
class="px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors bg-[#f97316] text-white">
|
||||||
<%= i == 0 ? 'text-yellow-400' : i == 1 ? 'text-gray-300' : i == 2 ? 'text-orange-600' : 'text-gray-400' %>">
|
Estimado
|
||||||
<%= i + 1 %>
|
</button>
|
||||||
</span>
|
<button type="button" data-aba="consolidado"
|
||||||
<div class="flex-1 min-w-0">
|
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">
|
||||||
<div class="flex items-center justify-between mb-1">
|
Consolidado
|
||||||
<span class="text-white text-sm font-medium truncate"><%= m[:nome] %></span>
|
</button>
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
</div>
|
||||||
<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>
|
<div id="ranking-painel-estimado">
|
||||||
<p class="text-sm">Nenhuma entrega concluída neste período</p>
|
<p class="text-gray-400 text-xs mb-3">Entregas atendidas × preço da entrega</p>
|
||||||
</div>
|
<%= render 'ranking_motoristas',
|
||||||
<% end %>
|
itens: @motoristas,
|
||||||
|
vazio: 'Nenhuma entrega atendida neste período' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -571,6 +583,38 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</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 %>
|
<%# Filtro de operação — submete o form ao marcar/desmarcar %>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -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/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/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>
|
<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">
|
<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>
|
||||||
<p class="text-black/60 text-sm mt-3">
|
<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.
|
<%= icone :alerta, cor: nil %> Valor aproximado. O valor certo sai quando a empresa fechar o pagamento.
|
||||||
|
|||||||
42
app/views/teste_mailer/teste.html.erb
Normal file
42
app/views/teste_mailer/teste.html.erb
Normal 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
252
bin/entregas_do_motorista
Executable 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
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
Devise.setup do |config|
|
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')
|
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'
|
require 'devise/orm/active_record'
|
||||||
config.case_insensitive_keys = [:email]
|
config.case_insensitive_keys = [:email]
|
||||||
config.strip_whitespace_keys = [:email]
|
config.strip_whitespace_keys = [:email]
|
||||||
|
|||||||
@@ -3,4 +3,7 @@
|
|||||||
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||||
inflect.irregular 'consolidacao', 'consolidacoes'
|
inflect.irregular 'consolidacao', 'consolidacoes'
|
||||||
inflect.irregular 'configuracao', 'configuracoes'
|
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
|
end
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# config/initializers/smtp.rb
|
# 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?
|
if ENV['SMTP_USERNAME'].present?
|
||||||
Rails.application.config.action_mailer.delivery_method = :smtp
|
Rails.application.config.action_mailer.delivery_method = :smtp
|
||||||
Rails.application.config.action_mailer.smtp_settings = {
|
Rails.application.config.action_mailer.smtp_settings = {
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
resources :auditoria_logs, only: [:index]
|
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)
|
# Edição de lançamento do SimpliRoute (correção de status/motivo/comentário)
|
||||||
# — só admin. Grava direto na API do SimpliRoute + AuditoriaLog.
|
# — só admin. Grava direto na API do SimpliRoute + AuditoriaLog.
|
||||||
resource :edicao_lancamento, only: [:show], controller: 'edicao_lancamentos' do
|
resource :edicao_lancamento, only: [:show], controller: 'edicao_lancamentos' do
|
||||||
|
|||||||
@@ -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
|
||||||
118
lib/tasks/entregas_motorista.rake
Normal file
118
lib/tasks/entregas_motorista.rake
Normal 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
|
||||||
189
spec/models/configuracao_notificacao_spec.rb
Normal file
189
spec/models/configuracao_notificacao_spec.rb
Normal 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
137
spec/models/entrega_spec.rb
Normal 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
|
||||||
@@ -8,7 +8,8 @@ RSpec.describe 'Nomes de tabela dos models' do
|
|||||||
Consolidacao => 'consolidacoes',
|
Consolidacao => 'consolidacoes',
|
||||||
ConsolidacaoMotorista => 'consolidacao_motoristas',
|
ConsolidacaoMotorista => 'consolidacao_motoristas',
|
||||||
ConsolidacaoEntrega => 'consolidacao_entregas',
|
ConsolidacaoEntrega => 'consolidacao_entregas',
|
||||||
Configuracao => 'configuracoes'
|
Configuracao => 'configuracoes',
|
||||||
|
ConfiguracaoNotificacao => 'configuracao_notificacoes'
|
||||||
}.each do |model, tabela|
|
}.each do |model, tabela|
|
||||||
it "#{model} aponta para #{tabela}" do
|
it "#{model} aponta para #{tabela}" do
|
||||||
expect(model.table_name).to eq(tabela)
|
expect(model.table_name).to eq(tabela)
|
||||||
|
|||||||
15
spec/policies/configuracao_notificacao_policy_spec.rb
Normal file
15
spec/policies/configuracao_notificacao_policy_spec.rb
Normal 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
|
||||||
134
spec/requests/admin/configuracao_notificacoes_spec.rb
Normal file
134
spec/requests/admin/configuracao_notificacoes_spec.rb
Normal 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
|
||||||
@@ -12,3 +12,209 @@ RSpec.describe 'Dashboard', type: :request do
|
|||||||
expect(response).to redirect_to(motorista_dashboard_path)
|
expect(response).to redirect_to(motorista_dashboard_path)
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|||||||
107
spec/requests/motorista_dashboard_spec.rb
Normal file
107
spec/requests/motorista_dashboard_spec.rb
Normal 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
|
||||||
102
spec/support/espelho_rastreio.rb
Normal file
102
spec/support/espelho_rastreio.rb
Normal 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
|
||||||
Reference in New Issue
Block a user