diff --git a/.env.example b/.env.example index d07d150..709af88 100644 --- a/.env.example +++ b/.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. FORCE_SSL=true +# ══════════════════════════════════════════════════════════════ +# NOTIFICAÇÕES (WhatsApp e e-mail) +# +# ⚠️ Estas variáveis são apenas FALLBACK. A configuração preferencial fica no +# BANCO e é editada pelo ADM em Configurações → Notificações e E-mail +# (/admin/configuracao_notificacao), valendo sem reiniciar o container. +# Enquanto a tela não for preenchida e ativada, valem os valores abaixo. +# ══════════════════════════════════════════════════════════════ + # ── Twilio — WhatsApp (opcional, Fase 8) ───────────────────── TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= @@ -57,6 +66,15 @@ SMTP_USERNAME= SMTP_PASSWORD= SMTP_DOMAIN=gade.com.br +# ── Criptografia dos segredos gravados pela tela (opcional) ── +# A senha do SMTP e o token do Twilio salvos no banco são cifrados com uma +# chave derivada do SECRET_KEY_BASE. Se você trocar o SECRET_KEY_BASE, esses +# dois valores viram ilegíveis (o sistema volta a usar o .env e a tela avisa +# para redigitar). Defina NOTIFICACAO_SECRET com uma string longa e FIXA para +# desacoplar os segredos da rotação do SECRET_KEY_BASE. +# ⚠️ Se mudar depois de já ter salvo, os valores gravados também se perdem. +NOTIFICACAO_SECRET= + # ── App ─────────────────────────────────────────────────────── APP_HOST=localhost:3000 APP_NAME=Reem Logística diff --git a/README.md b/README.md index f877f1d..e3d1a16 100644 --- a/README.md +++ b/README.md @@ -1786,3 +1786,330 @@ app/views/admin/configuracoes/index.html.erb # idem (arquivo distint +--- + +
+✉️ Notificações e E-mail configuráveis pela tela — SMTP + WhatsApp (11/08/2026) + +> ⚠️ **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 ` 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 `** 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. + +
+ + +--- + +
+💰 Entrega sem sucesso entra no pagamento + aba Consolidado no ranking (20–21/08/2026) + +> ⚠️ **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)" +``` + +
diff --git a/app/controllers/admin/configuracao_notificacoes_controller.rb b/app/controllers/admin/configuracao_notificacoes_controller.rb new file mode 100644 index 0000000..1352454 --- /dev/null +++ b/app/controllers/admin/configuracao_notificacoes_controller.rb @@ -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 diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index e1f349c..8cba40d 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -76,35 +76,43 @@ class DashboardController < ApplicationController @operacoes_agrupadas = Operacao.agrupadas_por_mes @operacao_filtro = Operacao.sanitizar(params[:operacoes]) - # PAGAS pela DATA REAL (checkout) — base de toda a análise financeira por data. - pagas = Entrega.da_conta_gade.pagas.no_periodo_checkout(@periodo_inicio, @periodo_fim) - pagas = pagas.da_operacoes(@operacao_filtro) if @operacao_filtro.present? + # ATENDIDAS pela DATA REAL (checkout) — base de toda a análise financeira. + # + # Atendida = o motorista FOI ao local e fechou a visita, com sucesso + # (`completed`) OU insucesso (`failed`). É exatamente o conjunto que a + # consolidação considera elegível e paga (ver Entrega::STATUS_ATENDIDO, + # Entrega.contar_atendidas e Consolidacao#veiculos_do_motorista), por isso o + # dashboard usa o MESMO recorte — usava só `pagas` (concluídas) e mostrava + # um valor menor que o do fechamento. + atendidas = Entrega.da_conta_gade.atendidas.no_periodo_checkout(@periodo_inicio, @periodo_fim) + atendidas = atendidas.da_operacoes(@operacao_filtro) if @operacao_filtro.present? - # PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada. + # PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada — + # é o único recorte sem checkout, então planned_date é o eixo correto aqui. pendentes = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim) pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present? - # FALHADAS (insucesso) pela data planejada — também não têm checkout. - falhadas = Entrega.da_conta_gade.falhadas.no_periodo(@periodo_inicio, @periodo_fim) - falhadas = falhadas.da_operacoes(@operacao_filtro) if @operacao_filtro.present? - - # Totais gerais - @entregas_pagas = pagas.count + # Quebra do total entre sucesso e insucesso (ambos já dentro de `atendidas`). + # ⚠️ Falhada TEM checkout: o motorista foi ao local e fechou a visita com + # motivo de insucesso. Por isso o eixo é checkout, igual às concluídas — antes + # as falhas eram filtradas por planned_date e caíam no período errado. + @entregas_pagas = atendidas.concluidas.count + @entregas_falhadas = atendidas.falhadas.count + @entregas_atendidas = @entregas_pagas + @entregas_falhadas @entregas_pendentes = pendentes.count - @entregas_falhadas = falhadas.count - @total_entregas = @entregas_pagas + @entregas_pendentes + @entregas_falhadas + @total_entregas = @entregas_atendidas + @entregas_pendentes # Configurações de preço config = Configuracao.mapa_de_precos # Valor estimado total - @valor_estimado = (@entregas_pagas * config[:entrega]).round(2) + @valor_estimado = (@entregas_atendidas * config[:entrega]).round(2) # Por motorista (todos) - @motoristas = pagas.group(:driver) - .count - .sort_by { |_, v| -v } - .map do |driver, qtd| + @motoristas = atendidas.group(:driver) + .count + .sort_by { |_, v| -v } + .map do |driver, qtd| { nome: driver, entregas: qtd, @@ -113,14 +121,14 @@ class DashboardController < ApplicationController end # Por local de entrega (contact_name) - @por_operacao = pagas.group(:contact_name) - .count - .sort_by { |_, v| -v } - .first(6) - .to_h + @por_operacao = atendidas.group(:contact_name) + .count + .sort_by { |_, v| -v } + .first(6) + .to_h # Evolução diária no período (para Chart.js) — por data real (checkout) - @grafico_diario = build_grafico_diario(pagas, config[:entrega]) + @grafico_diario = build_grafico_diario(atendidas, config[:entrega]) # Consolidações cujo período (data_inicio..data_fim) cruza o período do dashboard # — independe de quando foram criadas (created_at). @@ -219,22 +227,39 @@ class DashboardController < ApplicationController # Custo por motorista (todos) @fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total) .sort_by { |_, v| -v } + + # Ranking CONSOLIDADO (aba "Consolidado" do card Motoristas): o valor REAL + # fechado por motorista + quantas entregas entraram nesse fechamento. + # + # ⚠️ A quantidade é DISTINCT tracking_id, NÃO a contagem de linhas: a mesma + # entrega pode ter vários pilares (Normal + Bônus + Retirada = 3 linhas em + # consolidacao_entregas), e contar linhas inflaria o número de entregas. + # + # Partimos de @fin_por_motorista (que vem de `cms`, já .ativos) e só buscamos + # a quantidade: assim a aba lista exatamente os motoristas do fechamento — + # arquivado não aparece — e a soma bate com o KPI "Custo total" do topo. + qtd_por_motorista = ces.group(:motorista_nome).distinct.count(:tracking_id) + + @ranking_consolidado = @fin_por_motorista.map do |nome, valor| + { nome: nome, valor: valor, entregas: qtd_por_motorista[nome].to_i } + end end - # `pagas` já vem filtrado por checkout no período. Agrupa pela DATA REAL - # (DATE(checkout)) — DATE() sobre timestamp não faz conversão de fuso. - def build_grafico_diario(pagas, preco_entrega) + # `atendidas` já vem filtrado por checkout no período (concluídas + falhadas). + # Agrupa pela DATA REAL (DATE(checkout)) — DATE() sobre timestamp não faz + # conversão de fuso. + def build_grafico_diario(atendidas, preco_entrega) dias = (@periodo_inicio..@periodo_fim).to_a if dias.size <= MAX_DIAS_GRAFICO # Granularidade diária (períodos curtos) - contagem = pagas.group("DATE(checkout)").count.transform_keys(&:to_s) + contagem = atendidas.group("DATE(checkout)").count.transform_keys(&:to_s) labels = dias.map { |d| d.strftime('%d/%m') } valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) } qtds = dias.map { |d| contagem[d.to_s] || 0 } else # Período longo: agrega por mês para o gráfico não ficar vazio nem poluído. - contagem = pagas.group("TO_CHAR(checkout, 'YYYY-MM')").count + contagem = atendidas.group("TO_CHAR(checkout, 'YYYY-MM')").count meses = meses_no_periodo labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') } valores = meses.map { |m| ((contagem[m] || 0) * preco_entrega).round(2) } diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index 85f42ff..6a1e903 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -7,15 +7,27 @@ module Motorista def index nome = current_user.nome - # Card 1 — Valor ESTIMADO (mês atual): entregas pagas × preco_entrega + # Card 1 — Valor ESTIMADO (mês atual): entregas ATENDIDAS × preco_entrega. + # + # Atendida = o motorista FOI ao local e fechou a visita, com sucesso + # ('completed') OU insucesso ('failed') — no insucesso o deslocamento + # aconteceu e teve custo, então a empresa paga os dois. É o MESMO recorte do + # dashboard administrativo e da consolidação (ver Entrega::STATUS_ATENDIDO e + # Entrega.contar_atendidas): usava `pagas` (só concluídas) e o motorista via + # um valor MENOR que o do fechamento, sem entender de onde vinha a diferença. + # + # ⚠️ O eixo é o CHECKOUT (data real da visita), não planned_date: falhada TEM + # checkout, e uma entrega planejada em 31/07 mas fechada em 01/08 pertence a + # agosto — igual ao que a consolidação considera elegível. @estimado_inicio = Date.current.beginning_of_month @estimado_fim = Date.current - entregas_mes = Entrega.pagas.da_conta_gade - .do_motorista(nome) - .no_periodo(@estimado_inicio, @estimado_fim) - .count - @valor_estimado = entregas_mes * Configuracao.preco_entrega - @entregas_mes = entregas_mes + atendidas = Entrega.atendidas.da_conta_gade + .do_motorista(nome) + .no_periodo_checkout(@estimado_inicio, @estimado_fim) + @entregas_concluidas = atendidas.concluidas.count + @entregas_falhadas = atendidas.falhadas.count + @entregas_mes = @entregas_concluidas + @entregas_falhadas + @valor_estimado = @entregas_mes * Configuracao.preco_entrega # Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas @minhas_consolidacoes = Consolidacao.ativas diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 60e89c5..c74dca7 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,5 +1,22 @@ # app/mailers/application_mailer.rb +# +# O remetente e as credenciais SMTP saem do BANCO (ConfiguracaoNotificacao), em +# tempo de ENVIO — mudar na tela /admin/configuracao_notificacao vale sem +# reiniciar o container. +# +# ⚠️ Tem que ser `proc`, NÃO lambda. O Devise avalia o `default from:` com +# `instance_eval(&proc)`, que passa 1 argumento; um lambda de aridade 0 +# estouraria ArgumentError em todo "esqueci minha senha". +# +# ⚠️ E tem que ser `delivery_method_options`, NÃO um before_action mexendo em +# `message.delivery_method`: o `mail()` roda DEPOIS dos callbacks e chama +# `wrap_delivery_behavior!`, que sobrescreveria o callback. O +# `delivery_method_options` é lido dentro do próprio `mail()` e MERGEADO por +# cima do smtp_settings da classe — é daí que vem, de graça, o fallback para +# o que o config/initializers/smtp.rb montou a partir do .env. class ApplicationMailer < ActionMailer::Base - default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br') layout 'mailer' + + default from: proc { ConfiguracaoNotificacao.remetente }, + delivery_method_options: proc { ConfiguracaoNotificacao.opcoes_de_entrega } end diff --git a/app/mailers/consolidacao_mailer.rb b/app/mailers/consolidacao_mailer.rb index 6b07434..481be47 100644 --- a/app/mailers/consolidacao_mailer.rb +++ b/app/mailers/consolidacao_mailer.rb @@ -1,6 +1,7 @@ # app/mailers/consolidacao_mailer.rb class ConsolidacaoMailer < ApplicationMailer - default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br') + # Sem `default from:` aqui de propósito: redeclarar sobrescreveria o proc do + # ApplicationMailer e congelaria o remetente no valor que o .env tinha no boot. def pagamento_fechado(user, consolidacao, consolidacao_motorista) @user = user diff --git a/app/mailers/teste_mailer.rb b/app/mailers/teste_mailer.rb new file mode 100644 index 0000000..b36cff3 --- /dev/null +++ b/app/mailers/teste_mailer.rb @@ -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 diff --git a/app/models/concerns/atributo_cifrado.rb b/app/models/concerns/atributo_cifrado.rb new file mode 100644 index 0000000..f65d18c --- /dev/null +++ b/app/models/concerns/atributo_cifrado.rb @@ -0,0 +1,71 @@ +# app/models/concerns/atributo_cifrado.rb +# +# Cifra atributos sensíveis (senha SMTP, token do Twilio) em colunas +# `_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 diff --git a/app/models/configuracao.rb b/app/models/configuracao.rb index d42932a..2143321 100644 --- a/app/models/configuracao.rb +++ b/app/models/configuracao.rb @@ -11,6 +11,10 @@ class Configuracao < ApplicationRecord preco_extraordinaria preco_termo preco_termo_especial + # ⚠️ OBSOLETAS: quem liga/desliga notificação agora é ConfiguracaoNotificacao + # (tela /admin/configuracao_notificacao). Nenhum código lê estas duas chaves + # desde a migration CreateConfiguracaoNotificacoes, que copiou os valores. + # Continuam aqui só para não quebrar as linhas já existentes no banco. notificacao_whatsapp notificacao_email empresa_nome diff --git a/app/models/configuracao_notificacao.rb b/app/models/configuracao_notificacao.rb new file mode 100644 index 0000000..64eb8df --- /dev/null +++ b/app/models/configuracao_notificacao.rb @@ -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 diff --git a/app/models/entrega.rb b/app/models/entrega.rb index fe76fa2..3882316 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -121,12 +121,20 @@ class Entrega < ApplicationRecord # Veículos usados pelo(s) motorista(s) selecionado(s) no período — alimenta o # filtro dinâmico de veículos na criação de consolidação. + # + # ⚠️ Precisa usar EXATAMENTE o mesmo recorte da elegibilidade + # (`atendidas` + `no_periodo_checkout`, ver contar_atendidas e + # Consolidacao#veiculos_do_motorista). Usava `no_periodo` (planned_date), e o + # descasamento sumia com veículo do filtro: entrega planejada em 31/07 e + # entregue em 01/08 é elegível numa consolidação 01/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) nomes = Array(motoristas).reject(&:blank?) return [] if nomes.empty? base = da_conta_gade.where(driver: nomes) - base = base.no_periodo(inicio, fim) if inicio.present? && fim.present? + base = base.atendidas.no_periodo_checkout(inicio, fim) if inicio.present? && fim.present? base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?) end diff --git a/app/policies/configuracao_notificacao_policy.rb b/app/policies/configuracao_notificacao_policy.rb new file mode 100644 index 0000000..04b85c6 --- /dev/null +++ b/app/policies/configuracao_notificacao_policy.rb @@ -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 `?` 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 diff --git a/app/services/notificacao/cliente_twilio.rb b/app/services/notificacao/cliente_twilio.rb new file mode 100644 index 0000000..7e9868e --- /dev/null +++ b/app/services/notificacao/cliente_twilio.rb @@ -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 diff --git a/app/services/notificacao/resultado.rb b/app/services/notificacao/resultado.rb new file mode 100644 index 0000000..e259850 --- /dev/null +++ b/app/services/notificacao/resultado.rb @@ -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 diff --git a/app/services/notificacao/teste_email.rb b/app/services/notificacao/teste_email.rb new file mode 100644 index 0000000..81e2fca --- /dev/null +++ b/app/services/notificacao/teste_email.rb @@ -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 diff --git a/app/services/notificacao/teste_whatsapp.rb b/app/services/notificacao/teste_whatsapp.rb new file mode 100644 index 0000000..9f1b93f --- /dev/null +++ b/app/services/notificacao/teste_whatsapp.rb @@ -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 " 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 ").', + 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 diff --git a/app/services/notificacao_service.rb b/app/services/notificacao_service.rb index 53fee92..6f30929 100644 --- a/app/services/notificacao_service.rb +++ b/app/services/notificacao_service.rb @@ -1,10 +1,27 @@ # app/services/notificacao_service.rb # -# Envia notificações aos motoristas quando uma consolidação é finalizada. -# Canais controlados pelas configurações: notificacao_whatsapp / notificacao_email. -# Falhas de envio NUNCA quebram o fluxo de finalização (apenas logam). +# Envia notificações aos motoristas quando uma consolidação é finalizada ou +# quando o pagamento é efetuado. # +# Canais e credenciais vêm de ConfiguracaoNotificacao (tela /admin/ +# configuracao_notificacao), com fallback para o .env. +# +# Falhas de envio NUNCA quebram o fluxo de finalização — apenas logam. Isso é +# intencional: um SMTP fora do ar não pode impedir o fechamento de um pagamento. +# Para DIAGNOSTICAR o envio, use os botões de teste da tela, que usam +# Notificacao::TesteEmail / Notificacao::TesteWhatsapp e mostram o erro real. class NotificacaoService + EVENTOS = { + fechado: { mailer: :pagamento_fechado, + emoji: '🚚', + titulo: 'Seu pagamento de entregas foi fechado.', + cta: 'Acesse seu painel para baixar o extrato:' }, + pago: { mailer: :pagamento_efetuado, + emoji: '✅', + titulo: 'Seu pagamento foi efetuado.', + cta: 'Acesse seu painel para conferir:' } + }.freeze + def self.notificar_finalizacao(consolidacao) new(consolidacao).notificar_todos end @@ -16,106 +33,73 @@ class NotificacaoService def initialize(consolidacao) @consolidacao = consolidacao + @config = ConfiguracaoNotificacao.instancia end def notificar_todos - @consolidacao.consolidacao_motoristas.ativos.each do |cm| - user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase) - next unless user - - enviar_whatsapp(user, cm) if whatsapp_ativo? - enviar_email(user, cm) if email_ativo? && user.email.present? - end + @consolidacao.consolidacao_motoristas.ativos.each { |cm| notificar(cm, :fechado) } end - def enviar_aviso_pagamento(cm) - user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase) - return unless user - - enviar_whatsapp_pagamento(user, cm) if whatsapp_ativo? - enviar_email_pagamento(user, cm) if email_ativo? && user.email.present? + def enviar_aviso_pagamento(consolidacao_motorista) + notificar(consolidacao_motorista, :pago) end private - def whatsapp_ativo? - Configuracao.valor('notificacao_whatsapp') == 'true' && - ENV['TWILIO_ACCOUNT_SID'].present? + def notificar(cm, evento) + user = motorista_de(cm) + return unless user + + enviar_whatsapp(user, cm, evento) if @config.whatsapp_habilitado? + enviar_email(user, cm, evento) if @config.email_habilitado? && user.email.present? end - def email_ativo? - Configuracao.valor('notificacao_email') == 'true' + def motorista_de(cm) + User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase) + end + + def enviar_whatsapp(user, cm, evento) + # O telefone no cadastro pode estar como "(11) 92005-1157"; mandar esse + # texto cru para o Twilio devolve erro 21211 (destino inválido), que o + # rescue abaixo esconderia. Normalizar para E.164 é o que faz o envio + # realmente funcionar. + destino = ConfiguracaoNotificacao.canal(user.telefone) + return if destino.blank? + + credenciais = @config.credenciais_whatsapp + return if credenciais.blank? || credenciais[:from].blank? + + Notificacao::ClienteTwilio + .montar(credenciais[:sid], credenciais[:token]) + .messages.create(from: credenciais[:from], to: destino, body: mensagem(user, cm, evento)) + + Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}") + rescue StandardError => e + Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{e.class}: #{e.message}") + end + + def enviar_email(user, cm, evento) + ConsolidacaoMailer + .public_send(EVENTOS.fetch(evento)[:mailer], user, @consolidacao, cm) + .deliver_later + Rails.logger.info("[Notificacao] Email (#{evento}) agendado para #{user.email}") + rescue StandardError => e + Rails.logger.error("[Notificacao] Falha email (#{evento}) #{user.email}: #{e.class}: #{e.message}") + end + + def mensagem(user, cm, evento) + evt = EVENTOS.fetch(evento) + "Olá #{user.nome.split.first}! #{evt[:emoji]}\n" \ + "#{evt[:titulo]}\n" \ + "📋 #{@consolidacao.nome}\n" \ + "💰 Valor: #{moeda(cm.valor_total)}\n" \ + "#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" end # Moeda BR com separador de milhar: R$ 1.234,56 - def moeda(v) - inteiro, decimais = format('%.2f', v.to_f).split('.') + def moeda(valor) + inteiro, decimais = format('%.2f', valor.to_f).split('.') inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse "R$ #{inteiro},#{decimais}" end - - def mensagem(user, cm) - valor = moeda(cm.valor_total) - "Olá #{user.nome.split.first}! 🚚\n" \ - "Seu pagamento de entregas foi fechado.\n" \ - "📋 #{@consolidacao.nome}\n" \ - "💰 Valor: #{valor}\n" \ - "Acesse seu painel para baixar o extrato: " \ - "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" - end - - def enviar_whatsapp(user, cm) - return if user.telefone.blank? - - require 'twilio-ruby' - cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN']) - cliente.messages.create( - from: ENV['TWILIO_WHATSAPP_FROM'], - to: "whatsapp:#{user.telefone}", - body: mensagem(user, cm) - ) - Rails.logger.info("[Notificacao] WhatsApp enviado para #{user.nome}") - rescue => e - Rails.logger.error("[Notificacao] Falha WhatsApp #{user.nome}: #{e.message}") - end - - def enviar_email(user, cm) - ConsolidacaoMailer.pagamento_fechado(user, @consolidacao, cm).deliver_later - Rails.logger.info("[Notificacao] Email agendado para #{user.email}") - rescue => e - Rails.logger.error("[Notificacao] Falha email #{user.email}: #{e.message}") - end - - # ── Aviso de pagamento efetuado ───────────────────────────── - def mensagem_pagamento(user, cm) - valor = moeda(cm.valor_total) - "Olá #{user.nome.split.first}! ✅\n" \ - "Seu pagamento foi efetuado.\n" \ - "📋 #{@consolidacao.nome}\n" \ - "💰 Valor: #{valor}\n" \ - "Acesse seu painel para conferir: " \ - "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" - end - - def enviar_whatsapp_pagamento(user, cm) - return if user.telefone.blank? - - require 'twilio-ruby' - cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN']) - cliente.messages.create( - from: ENV['TWILIO_WHATSAPP_FROM'], - to: "whatsapp:#{user.telefone}", - body: mensagem_pagamento(user, cm) - ) - Rails.logger.info("[Notificacao] WhatsApp pagamento enviado para #{user.nome}") - rescue => e - Rails.logger.error("[Notificacao] Falha WhatsApp pagamento #{user.nome}: #{e.message}") - end - - def enviar_email_pagamento(user, cm) - ConsolidacaoMailer.pagamento_efetuado(user, @consolidacao, cm).deliver_later - Rails.logger.info("[Notificacao] Email pagamento agendado para #{user.email}") - rescue => e - Rails.logger.error("[Notificacao] Falha email pagamento #{user.email}: #{e.message}") - end end diff --git a/app/views/admin/configuracao_notificacoes/show.html.erb b/app/views/admin/configuracao_notificacoes/show.html.erb new file mode 100644 index 0000000..62fdf5f --- /dev/null +++ b/app/views/admin/configuracao_notificacoes/show.html.erb @@ -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]' +%> + +
+ +
+
+

+ <%= icone :email, espaco: false %> Notificações e E-mail +

+

+ Servidor de e-mail (SMTP) e WhatsApp (Twilio) usados pelo sistema +

+
+ <%= 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' %> +
+ + <%= render 'shared/flash' %> + + <%# Status atual — o que o sistema REALMENTE vai usar agora %> +
+
+ + E-mail: + + <%= @config.smtp_pronto? ? 'configurado' : 'usando .env / desligado' %> + +
+
+ + WhatsApp: + + <%= @config.whatsapp_pronto? ? 'configurado' : 'usando .env / desligado' %> + +
+
+ + <%# 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? %> +
+ Não foi possível ler <%= ilegiveis.to_sentence %> que estava gravada no banco + (o SECRET_KEY_BASE do servidor mudou). + Digite novamente e salve. Enquanto isso o sistema usa o que está no arquivo .env. +
+ <% end %> + + <%= form_with(model: @config, url: admin_configuracao_notificacao_path, method: :patch, + class: 'space-y-6') do |f| %> + + <% if @config.errors.any? %> +
+

+ <%= pluralize(@config.errors.count, 'erro', 'erros') %> encontrado<%= @config.errors.count > 1 ? 's' : '' %>: +

+
    + <% @config.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + + <%# ─────────────────────────── E-MAIL / SMTP ─────────────────────────── %> +
+
+
+

+ <%= icone :email, espaco: false %> Servidor de e-mail (SMTP) +

+

+ Usado no envio de extratos, avisos de pagamento e recuperação de senha +

+
+ +
+ +
+
+ <%= f.label :smtp_address, 'Servidor', class: rotulo %> + <%= f.text_field :smtp_address, class: input, placeholder: 'smtp.gmail.com' %> +
+ +
+ <%= f.label :smtp_port, 'Porta', class: rotulo %> + <%= f.number_field :smtp_port, min: 1, max: 65535, class: input, placeholder: '587' %> +

587 para STARTTLS (padrão) ou 465 para SSL direto.

+
+ +
+ <%= f.label :smtp_username, 'Usuário', class: rotulo %> + <%= f.text_field :smtp_username, autocomplete: 'off', class: input, + placeholder: 'sistema@suaempresa.com.br' %> +
+ +
+ <%= 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' %> +

+ No Gmail/Google Workspace use uma Senha de app de 16 caracteres, + não a senha da conta. +

+
+ +
+ <%= f.label :smtp_autenticacao, 'Autenticação', class: rotulo %> + <%= f.select :smtp_autenticacao, + options_for_select(ConfiguracaoNotificacao::AUTENTICACOES, @config.smtp_autenticacao), + {}, class: "#{input} cursor-pointer" %> +
+ +
+ <%= f.label :smtp_domain, 'Domínio (opcional)', class: rotulo %> + <%= f.text_field :smtp_domain, class: input, placeholder: 'suaempresa.com.br' %> +
+ +
+ <%= f.label :remetente_email, 'E-mail remetente', class: rotulo %> + <%= f.email_field :remetente_email, class: input, placeholder: 'nao-responda@suaempresa.com.br' %> +

Em branco, usa o próprio usuário do SMTP.

+
+ +
+ <%= f.label :remetente_nome, 'Nome do remetente', class: rotulo %> + <%= f.text_field :remetente_nome, class: input, placeholder: 'Reem Transporte' %> +
+
+ +
+
+ <%= f.label :email_notificacoes_ativo, 'Avisar motoristas por e-mail', class: 'block text-sm font-medium text-gray-300' %> +

+ Envia o aviso de pagamento fechado e de pagamento efetuado para o motorista +

+
+ +
+
+ + <%# ────────────────────────── WHATSAPP / TWILIO ───────────────────────── %> +
+
+
+

+ <%= icone :telefone, espaco: false %> WhatsApp (Twilio) +

+

+ Credenciais em Twilio Console → Account Info +

+
+ +
+ +
+
+ <%= 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' %> +
+ +
+ <%= 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' %> +
+ +
+ <%= 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' %> +

+ Precisa ser o número do Sandbox ou um Sender aprovado no Twilio — + não dá para usar um número de WhatsApp comum. +

+
+
+ +
+

+ Sandbox do Twilio: antes do primeiro envio, o + número que vai receber precisa mandar join <sua-palavra> + no WhatsApp para o número do sandbox. Sem esse opt-in o Twilio recusa com o erro 63003/63015. +

+
+
+ + <%# ───────────────────── DESTINATÁRIOS ADMINISTRATIVOS ────────────────── %> +
+
+

+ <%= icone :pessoa, espaco: false %> Destinatários administrativos +

+

+ Para onde vão os testes abaixo e os avisos destinados à administração +

+
+ +
+
+ <%= f.label :email_admin, 'E-mail do administrador', class: rotulo %> + <%= f.email_field :email_admin, class: input, placeholder: 'voce@suaempresa.com.br' %> +
+ +
+ <%= f.label :whatsapp_admin, 'WhatsApp do administrador', class: rotulo %> + <%= f.text_field :whatsapp_admin, class: input, placeholder: '11 92005-1157' %> +

Com DDD. É gravado no formato internacional (+55…).

+
+
+
+ + <%# ──────────────────────────────── 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. %> +
+
+ <%= 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 %> +
+

+ Os testes salvam as alterações antes de enviar e mostram o erro exato retornado + pelo servidor de e-mail ou pelo Twilio. +

+
+ <% end %> +
diff --git a/app/views/admin/configuracoes/index.html.erb b/app/views/admin/configuracoes/index.html.erb index 8fbfee7..846710a 100644 --- a/app/views/admin/configuracoes/index.html.erb +++ b/app/views/admin/configuracoes/index.html.erb @@ -8,6 +8,26 @@ <%= render 'shared/flash' %> + <%# Configurações sensíveis (credenciais de envio) — só ADM %> + <% if current_user.admin? %> +
+

<%= icone :travado, espaco: false %> Configurações do Administrador

+
+ <%= 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 %> +
✉️
+
+

Notificações e E-mail

+

+ Servidor de e-mail (SMTP), WhatsApp via Twilio e os contatos que recebem os avisos da administração +

+
+ <% end %> +
+
+ <% end %> + <%# Pilares de preço %>

<%= icone :dinheiro, espaco: false %> Pilares de Preço

diff --git a/app/views/dashboard/_ranking_motoristas.html.erb b/app/views/dashboard/_ranking_motoristas.html.erb new file mode 100644 index 0000000..fdb5bb8 --- /dev/null +++ b/app/views/dashboard/_ranking_motoristas.html.erb @@ -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? %> +
+ <%# 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| %> +
+ + <%= i + 1 %> + +
+
+ <%= m[:nome] %> + + <%= moeda(m[:valor]) %> + +
+ <%# Barra de progresso %> +
+
+
+ <%= m[:entregas] %> entregas +
+
+ <% end %> +
+<% else %> +
+
<%= icone :caminhao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %>
+

<%= vazio %>

+
+<% end %> diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 167d030..5256fb3 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -134,8 +134,14 @@

<%= moeda(@valor_estimado) %>

+ <%# "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. %>

- <%= @entregas_pagas %> entregas concluídas + <%= @entregas_atendidas %> entregas atendidas +

+

+ <%= @entregas_pagas %> concluídas · <%= @entregas_falhadas %> falhadas

@@ -256,42 +262,48 @@ <% end %> - <%# 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. %>
-

<%= icone :ranking, espaco: false %> Motoristas

+
+

<%= icone :ranking, espaco: false %> Motoristas

- <% if @motoristas.any? %> -
- <% @motoristas.each_with_index do |m, i| %> -
- - <%= i + 1 %> - -
-
- <%= m[:nome] %> - - <%= moeda(m[:valor]) %> - -
- <%# Barra de progresso %> -
- <% max = @motoristas.first[:entregas].to_f %> -
-
- <%= m[:entregas] %> entregas -
-
- <% end %> + <%# Mesmas pílulas da paginação de pagamentos (as classes estão duplicadas + no JS que troca a aba — mexeu aqui, mexa lá). %> +
+ +
- <% else %> -
-
<%= icone :caminhao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %>
-

Nenhuma entrega concluída neste período

-
- <% end %> +
+ +
+

Entregas atendidas × preço da entrega

+ <%= render 'ranking_motoristas', + itens: @motoristas, + vazio: 'Nenhuma entrega atendida neste período' %> +
+ + <%# Sem JS a aba fica escondida — aceitável: o mesmo dado consolidado + também aparece no gráfico "Custo por motorista" logo abaixo. %> +
@@ -571,6 +583,38 @@ })(); +<%# Abas do ranking de motoristas (Estimado × Consolidado) %> + + <%# Filtro de operação — submete o form ao marcar/desmarcar %>