Atualização de Read.me

This commit is contained in:
2026-08-27 19:57:33 -03:00
parent e2b74068d5
commit ab126d13db

333
README.md
View File

@@ -3072,118 +3072,291 @@ caminho do servidor e o trace inteiro — para **qualquer pessoa** que provocass
---
<details>
<summary><strong>🕐 Relógio, restart automático e migrations no boot</strong></summary>
<summary><strong>🛠️ 27/08/2026 — Deploy: relógio, arquivos órfãos e tudo que caiu junto</strong></summary>
## O problema relatado
Um relato só, porque foi **uma corrente**: um problema descoberto expunha o
seguinte. Vale mais como mapa de diagnóstico do que como changelog.
> "toda vez que faço o deploy ou reinicio o Docker a data e hora ficam erradas."
São **dois problemas diferentes** que se confundem porque aparecem no mesmo lugar:
| Sintoma | Causa | Onde se resolve |
| Sintoma na tela | Causa real | Onde se resolveu |
|---|---|---|
| Hora **sempre 3h adiantada**, todo restart | O container subia em **UTC** | `Dockerfile` (fuso) |
| Hora **derivando / errada depois de reboot** | Relógio do **host** sem sincronia | `deploy/ntp-seguro.sh` (NTS) |
| Hora sempre 3h adiantada | Container subia em **UTC** | `Dockerfile` (TZ + `/etc/localtime`) |
| Hora errada depois de reboot | Relógio do **host** sem sincronia | `deploy/ntp-seguro.sh` (chrony + NTS) |
| `env file .env not found` | `.env` não é versionado e a pasta foi recriada | recriar no servidor + backup fora da árvore |
| `Couldn't find Active Storage configuration` | `config/storage.yml` nunca esteve no git | arquivo versionado |
| `port is already allocated` | Porta 3001 vivia como alteração **local** no compose | `${PORTA_APP:-3001}` |
| `Blocked hosts: 100.75.222.23:3001` | `config.hosts` ligado só com o domínio | `HOSTS_PERMITIDOS` |
| `Translation missing … devise.failure` | `fallbacks = true` caía no próprio pt-BR | `fallbacks = [:en]` + traduções |
| 422 "recusada por verificação de segurança" | `assume_ssl` fixo x acesso por IP em HTTP | gate do `FORCE_SSL` |
| `URI::InvalidURIError: … SEU_IP` | `DATABASE_URL` com o valor de exemplo | falha rápida no boot |
### 1. Fuso do container (o "sempre 3h à frente")
## 1. Relógio: fuso é do container, hora é do host
A imagem `ruby:3.2.2-slim` sobe em UTC. O Rails até mostrava a hora certa
(`config.time_zone = "America/Sao_Paulo"`), mas **tudo que era do sistema
operacional continuava adiantado** — e o pior caso não era visual:
A imagem `ruby:3.2.2-slim` sobe em UTC. O Rails mostrava a hora certa
(`config.time_zone`), mas **tudo do sistema operacional** ficava adiantado — e o
pior caso não era visual:
> **O cron rodava no horário errado.** `every "*/30 8-18"` no `config/schedule.rb`
> executava das **05h às 15h** de Brasília, não das 08h às 18h.
> **O cron rodava no horário errado.** `every "*/30 8-18"` executava das **05h às
> 15h** de Brasília.
Agora o `Dockerfile` fixa `TZ=America/Sao_Paulo` **e** o symlink de
`/etc/localtime` — os dois, porque o cron do Debian lê o arquivo, não a
variável. O mesmo foi feito no `whatsapp/Dockerfile` (Alpine precisa do pacote
`tzdata`, que não vem na imagem). Dá para ajustar sem rebuild pelo `TZ` do `.env`.
O `Dockerfile` agora fixa `TZ=America/Sao_Paulo` **e** o symlink de
`/etc/localtime` — os dois, porque o cron do Debian lê o arquivo, não a variável.
Mesmo tratamento no `whatsapp/Dockerfile` (Alpine precisa do pacote `tzdata`).
### 2. Hora do host — NTP autenticado (NTS)
**Não dá para sincronizar o relógio de dentro do container**: ele lê o clock do
kernel do host. Rodar NTP lá dentro exigiria `CAP_SYS_TIME` e mudaria a hora do
servidor inteiro a partir de um processo da aplicação.
No servidor, uma vez:
**A hora em si não dá para sincronizar de dentro do container**: ele lê o clock do
kernel do host, e rodar NTP lá dentro exigiria `CAP_SYS_TIME`. Por isso
`deploy/ntp-seguro.sh`, que roda **uma vez no servidor** e instala **chrony com
NTS** (RFC 8915): troca de chaves por TLS (TCP 4460) e pacotes NTP assinados.
NTP comum é UDP sem autenticação, e hora errada aqui muda **o dia da
consolidação, o recorte do período financeiro e a validade da sessão**. O script
usa 3 fontes independentes, **desliga as não autenticadas** (deixar o `pool`
padrão anularia o ganho), desativa o `systemd-timesyncd` e liga o `rtcsync`.
```bash
sudo bash deploy/ntp-seguro.sh
chronyc tracking # 'System time' na casa dos milissegundos
chronyc -N authdata # NTS ativo em cada fonte
```
O script instala e configura o **chrony com NTS** (RFC 8915): a troca de chaves
é por TLS (TCP 4460) e os pacotes NTP vêm assinados, então uma resposta forjada
no caminho é descartada. NTP comum é UDP sem autenticação — e hora errada aqui
não é detalhe: ela muda **o dia da consolidação, o recorte do período financeiro
e a validade da sessão**.
## 2. Restart automático e migrations
O script ainda: usa **3 fontes independentes** (Cloudflare, Netnod, PTB),
**desliga as fontes não autenticadas** (deixar o `pool` padrão anularia o ganho),
desativa o `systemd-timesyncd` (não fala NTS e brigaria pelo relógio), liga o
`rtcsync` (é o relógio de hardware que dá a hora no boot) e mostra a
conferência no fim. É idempotente e guarda `.bak` da config.
`restart: unless-stopped` no serviço `app`. Escolhido em vez de `always` porque
`always` reergue o container até depois de um `stop` deliberado, tirando do
operador a chance de deixar o sistema fora do ar de propósito.
Conferir depois, a qualquer momento:
O boot saiu do `command:` de uma linha e virou **`bin/docker-boot`**:
```bash
chronyc tracking # 'System time' deve ficar na casa dos milissegundos
chronyc -N authdata # NTS ativo em cada fonte (Cook > 0)
1. **relógio** — imprime hora e fuso (se aparecer UTC, o rebuild não pegou);
2. **pid órfão**; 3. **gems** (o volume `bundle_cache` sombreia os da imagem);
4. **migrations** — `db:prepare`, com **espera pelo banco**;
5. **cron** (best-effort); 6. **Puma**.
A migration é automática a todo restart. A espera do passo 4 existe porque o
PostgreSQL é externo ao compose: num reboot o Rails pode subir antes de o banco
aceitar conexão e, com `unless-stopped`, isso viraria **loop de reinício**
parecendo erro de migration.
**Falha rápida:** `DATABASE_URL` vazia ou com o valor de exemplo não é "banco fora
do ar" — nenhuma espera resolve. O boot para na primeira tentativa dizendo o que
preencher, **com a credencial mascarada no log**. Este projeto **não tem
`config/database.yml`**: a conexão inteira sai dessa variável, e ninguém lê
`DB_HOST`/`DB_NAME`/`DB_USER`/`DB_PASSWORD`.
## 3. Arquivos que o git não levava — e por isso sumiram juntos
A pasta do deploy foi recriada e levou embora todo arquivo **não versionado**. Dois
apareceram:
- **`.env`** — está no `.gitignore` por conter senha, e assim deve continuar.
Guarde uma cópia fora da árvore do projeto e prefira `git pull`/`reset --hard`
na própria pasta a recriá-la (arquivo ignorado sobrevive ao pull).
- **`config/storage.yml`** — não tinha motivo para estar de fora. O app faz
`require "rails/all"`, então o Active Storage sempre carrega e os
`config/environments/*.rb` definem `active_storage.service = :local`: com um
serviço definido, o Rails **exige** o arquivo no `initialize!` e o servidor não
sobe. **Agora é versionado.**
## 4. Configuração do ambiente saiu do `.env` e entrou no compose
A porta do teste (**3001**, porque a 3000 está ocupada por outro stack) vivia como
alteração **local** no `docker-compose.yml` do servidor — e um `git reset --hard`
a levou junto, derrubando o ambiente com o proxy apontando para uma porta sem
ninguém escutando. Agora os valores **não secretos** têm padrão no compose:
```yaml
ports:
- "${PORTA_APP:-3001}:3000"
environment:
TZ: ${TZ:-America/Sao_Paulo}
APP_HOST: ${APP_HOST:-teste.reemtransportes.com.br}
APP_NAME: "${APP_NAME:-Reem Logística}"
WHATSAPP_URL: ${WHATSAPP_URL:-http://whatsapp:3001}
HOSTS_PERMITIDOS: ${HOSTS_PERMITIDOS:-100.75.222.23}
```
> **Firewall:** precisa de saída em **UDP 123** e **TCP 4460**. Sem a 4460 o NTS
> não fecha e o chrony fica sem fonte.
`${VAR:-padrão}`: **o `.env` continua mandando**; o padrão só entra quando a
variável falta. Segredos (`DB_*`, `SECRET_KEY_BASE`, `SIMPLIROUTE_TOKEN`,
`WHATSAPP_TOKEN`, `SMTP_*`, `NOTIFICACAO_SECRET`) **nunca** vão para o yaml.
## Restart automático + migrations
> ⚠️ **`APP_HOST` é só o host, sem `https://`.** O código monta os links como
> `"https://#{APP_HOST}/motorista"` (`consolidacao_mailer.rb`,
> `notificacao_service.rb`, `gatilhos.rb`): com o esquema no valor sai
> `https://https://…` e todo link de e-mail e WhatsApp quebra.
`docker-compose.yml`, serviço `app`: **`restart: unless-stopped`**. Sobe sozinho
depois de queda do processo e de reboot do servidor, e só fica parado se alguém
der `docker compose stop/down`. (`always` foi descartado: ele reergue o container
até depois de um `stop` deliberado, tirando do operador a chance de deixar o
sistema fora do ar de propósito.)
## 5. Produção de verdade: o que só aparece fora do modo development
O boot saiu do `command:` de uma linha só e virou **`bin/docker-boot`**, com o
motivo de cada passo comentado:
O servidor passou a rodar `RAILS_ENV=production`, e três coisas apareceram:
1. **relógio** — imprime a hora e o fuso no log (se aparecer UTC, o rebuild não pegou);
2. **pid órfão** — sem limpar, o Puma se recusa a subir depois de uma queda;
3. **gems** — `bundle check || bundle install` (o volume `bundle_cache` sombreia os gems da imagem);
4. **migrations** — `db:prepare`, **com espera pelo banco**;
5. **cron** — `whenever --update-crontab` + daemon, *best-effort*;
6. **Puma**.
- **`config.hosts`** — a linha que parecia só liberar o `APP_HOST` na verdade
**liga** a verificação e recusa todo o resto. Agora entram também
`localhost`/`127.0.0.1` (para o `curl` de dentro do NAS servir de diagnóstico) e
a lista `HOSTS_PERMITIDOS`.
- **`i18n.fallbacks = true`** significa "caia no `default_locale`" — que aqui é o
próprio pt-BR. O fallback apontava para si mesmo e chave ausente virava
`Translation missing` **na tela do usuário** (tela de login). Agora `[:en]`,
igual ao `application.rb`, e o `pt-BR.yml` ganhou as traduções do Devise
(`failure`, `sessions`, `passwords`) — o projeto não usa `devise-i18n`.
- **`assume_ssl`** estava fixo, então `request.base_url` virava `https://` e o
`Origin: http://` de quem abre pelo IP não batia: **todo POST voltava 422**
("recusada por uma verificação de segurança") enquanto os GETs passavam e a tela
parecia normal. Foi o que quebrou a importação do plano. Um gate `FORCE_SSL=false`
devolve o acesso por HTTP para diagnóstico; sem a variável, HTTPS continua
obrigatório.
Ou seja: **a migration é automática a todo restart** — `db:prepare` cria o banco
se não existir, aplica as migrations pendentes e só faz seed em banco novo
(idempotente). Não há passo manual depois do deploy.
> **Proxy reverso:** o destino tem que ser **HTTP** na porta do container. Com
> `https://` o Puma responde
> `Are you trying to open an SSL connection to a non-SSL Puma?`.
> **Por que a espera no passo 4:** o PostgreSQL é externo ao compose. Num reboot
> do servidor o Rails pode subir antes de o banco aceitar conexão — e agora, com
> `restart: unless-stopped`, isso viraria um **loop de reinício** parecendo erro
> de migration. São 10 tentativas × 6s. Se falhar depois disso (migration
> quebrada, credencial errada), o container sai com erro **de propósito**: melhor
> do que servir a aplicação contra um schema desatualizado.
## 6. `db/schema.rb` entrou no `.gitignore`
## Deploy
Nunca foi versionado (o schema vive nas migrations). Como o compose monta
`.:/app`, o `db:prepare` do boot escreve o arquivo **no host** — e um `git add .`
no meio de um rebase o capturou e virou conflito de deploy. Em
`RAILS_ENV=production` ele nem seria gerado (`dump_schema_after_migration = false`).
> **Deploy é `git fetch` + `git reset --hard origin/<branch>`**, não `pull --rebase`:
> o container reescreve `Gemfile.lock` e `db/schema.rb` no host, então o checkout
> produz alteração local sozinho e todo rebase bate de frente com ela.
## ▶️ Deploy
```bash
# 1. No servidor, uma única vez — relógio com NTP autenticado:
sudo bash deploy/ntp-seguro.sh
# 2. Rebuild obrigatório (o fuso entra na imagem):
docker compose up -d --build
# 3. Conferir no log que o container está em -03 e não em UTC:
docker compose logs app | grep '\[boot\] relógio'
sudo bash deploy/ntp-seguro.sh # 1x no servidor
git fetch origin && git reset --hard origin/teste
docker compose up -d --build # --build: o fuso entra na imagem
docker compose logs app | grep '\[boot\]'
curl -I http://127.0.0.1:3001/ # separa "app caiu" de "proxy errado"
```
## 📂 Arquivos
```
Dockerfile # TZ=America/Sao_Paulo + tzdata + /etc/localtime; CMD -> bin/docker-boot
whatsapp/Dockerfile # tzdata (Alpine) + mesmo fuso
docker-compose.yml # restart: unless-stopped, TZ, command: bin/docker-boot
bin/docker-boot # boot documentado: migrations com espera, cron, Puma — NOVO
deploy/ntp-seguro.sh # chrony + NTS no host (rodar 1x, como root) — NOVO
.env.example # TZ=America/Sao_Paulo + aviso "fuso ≠ hora"
Dockerfile / whatsapp/Dockerfile # TZ + tzdata + /etc/localtime
docker-compose.yml # restart, PORTA_APP, APP_HOST/NAME, TZ, HOSTS_PERMITIDOS
bin/docker-boot # boot documentado: espera do banco, cron, Puma — NOVO
deploy/ntp-seguro.sh # chrony + NTS no host (1x, root) — NOVO
config/storage.yml # versionado — NOVO
config/environments/production.rb # hosts, i18n fallbacks, gate do FORCE_SSL
config/locales/pt-BR.yml # traduções do Devise
.gitignore # db/schema.rb
.env.example # TZ, PORTA_APP, HOSTS_PERMITIDOS, APP_HOST sem esquema
```
</details>
---
<details>
<summary><strong>🚚 Romaneio — a tela recuperou as funções do programa antigo</strong></summary>
Referência: o **Romaneiro PDF (GADE Hospitalar)**, o programa em Python que a
operação usava. O PDF já estava fechado; o que faltava era a **tela**.
## Veículos: lista visível, não um `<select>`
Era um `<select>` com , escolhido para não virar armadilha de scroll com 72
carros. Resolveu o scroll e criou outro problema: o operador via **um carro por
vez** e perdia a contagem de paradas de cada um — que é justamente a conferência
feita antes de imprimir.
Agora é a lista inteira, cada item com a contagem (`GADE_038 · 31`), o atual em
laranja, rolagem própria, e as setas preservadas para percorrer carro a carro.
Cada veículo é um **link de verdade** (`?veiculo=GADE_038`): volta com o botão
"voltar" do navegador e sobrevive a um F5. No celular vira faixa horizontal —
72 itens empilhados empurrariam a tabela para fora da primeira tela.
Na mesma coluna, como no original: **motorista do veículo**, **PDF deste veículo**
e **PDF de TODOS os veículos**.
## Prévia sob demanda, em sobreposição
Antes o `<iframe>` do PDF era montado em **todo** carregamento da tela — e cada
montagem gerava um PDF no servidor, inclusive quando ninguém ia olhar. Agora é o
botão **"Ver prévia do PDF"**: abre sobreposta, fundo escurecido, ``/Esc/clique
fora fecham (clique **dentro** do PDF não fecha, senão rolar o documento fecharia
a prévia). Editar com a prévia fechada só a marca como desatualizada; o PDF novo
sai no próximo "Ver prévia".
## Busca com "Limpar" e contador vivo
`4 de 31 paradas — filtrando por "jardim"`. A contagem **saiu do cabeçalho da
tabela**: dois números para a mesma coisa, um atualizando e o outro não, é a
divergência que a diretriz 1 proíbe. Sem contador, uma busca que não casa com nada
deixa a tabela vazia e parece plano vazio.
## NOVOS × RECORRENTES no topo
> **184** com aparelho (NOVOS) · **1863** recorrentes
É o equivalente à caixa de diálogo que o programa antigo mostrava ao aplicar o
status. Verde quando há novos, **âmbar quando é zero** — e zero quase sempre
significa importação **sem operação vinculada**, em que a coluna APARELHO sai
vazia para todos. Um romaneio assim parece idêntico a um certo e só revela o erro
no papel, com aparelho não entregue.
## Logo escolhível
Era fixo em `public/logo-gade.png`. Agora vem de `Configuracao` (chave
`romaneio_logo`, separada do `empresa_logo`, que é a marca da Reem — o romaneio é
documento do **cliente**). PNG ou JPG até 2 MB, gravado em `storage/logos/` e
**não** em `public/`: o que o operador sobe não vira arquivo servido pela web sem
autorização, e o nome do arquivo é gerado pelo sistema, nunca o que vem do
navegador.
## Operação deduzida pelo NOME DO PLANO
A operação não trabalha por data — identifica o plano pelo nome
(`ENTREGAS EMAD 09.2026`). E é a operação do mês que diz quem é **NOVO**.
`Romaneios::CasadorDeOperacao` compara por **conjunto de palavras + (mês, ano)**,
não por texto, porque as diferenças são sistemáticas:
| Nome do plano | Tabela da operação |
|---|---|
| `ENTREGAS EMAD 09.2026` | `gade_entregas_emad_set_2026` |
| `UBS SUL E LESTE AGOSTO 2026` | `gade_entregas_ubs_sul_leste_ago_2026` |
| `AVULSAS 3 EMAD/SUDESTE AGOSTO 2026` | `gade_entregas_avulsas_3_emad_sudeste_ago_2026` |
Três armadilhas: o conectivo **E**, o mês **abreviado × por extenso**, e o mês
**numérico** (`09.2026`) — este só lido como mês quando vem colado ao ano, senão o
`3` de "AVULSAS 3" viraria março. Usa a tabela `MESES` do próprio `Operacao`
(via `Operacao.mes_numero`) em vez de duplicá-la.
**Empate devolve `nil` de propósito.** Vincular a operação errada é pior do que
não vincular: APARELHO e TELEFONE sairiam preenchidos com dados de **outro mês** e
ninguém desconfiaria. A tela avisa quando ficou sem operação, e a importação diz o
que deduziu ("Operação EMAD SET 2026 vinculada pelo nome do plano") — dedução
silenciosa que acerta é invisível, mas a que erra é indefensável.
## Sobre buscar o plano por nome na API
**Não é possível.** A documentação
(https://documentation.simpliroute.com) não expõe endpoint que **liste** planos —
só `POST /v1/plans/create-plan/` (onde o plano tem `name`),
`GET /v1/plans/{planned_date}/vehicles/` e `GET /v1/plans/routes/{PLAN_ID}/visits/`.
A lista de planos que aparece no site deles é da interface web. Por isso o nome é
**colado pelo operador** e casado localmente.
> `GET /v1/plans/{planned_date}/vehicles/` devolve veículos + UUIDs de rota por
> data — é o degrau (a) que o `Romaneios::PlanoDoDia` hoje tenta adivinhar. Vale
> checar com `bin/sondar_plano_do_dia`, que ganhou uma seção `[4]` procurando
> endpoint de planos com nome.
## 📂 Arquivos
```
app/views/admin/romaneios/_veiculos.html.erb # lista de veículos — NOVO
app/views/admin/romaneios/_controles.html.erb # busca + Limpar + contador vivo
app/views/admin/romaneios/show.html.erb # 2 zonas + prévia em sobreposição
app/views/admin/romaneios/index.html.erb # logo + "deduzir pelo nome do plano"
app/javascript/controllers/romaneio_controller.js # navegação, filtro, overlay
app/services/romaneios/casador_de_operacao.rb # nome do plano → operação — NOVO
spec/services/romaneios/casador_de_operacao_spec.rb # os nomes reais — NOVO
app/models/romaneio.rb # contagem_aparelho (NOVOS x recorrentes)
app/models/operacao.rb # mes_numero público
app/models/configuracao.rb # chave romaneio_logo
app/services/pdf/romaneio_pdf.rb # logo configurável
app/controllers/admin/romaneios_controller.rb # atualizar_logo + dedução da operação
config/routes.rb # POST atualizar_logo
```
</details>