# Ambiente de teste — https://sistema.franquiabv.xyz (servidor BVIP)

Escrito em 24/09/2026, refeito em 25/09/2026 depois de duas revisões
adversariais, e acertado em 26/09/2026 pela quarta, pela quinta e pela sexta. A
senha HTTP e a subida foram acertadas em 08/10/2026 (SUBIR-COM-SENHA): até ali a
página mandava publicar sem ela. O ambiente onde o ERP e a tela nova (React) são testados por
gente, num endereço público. Mora no BVIP, ao lado da homologação
(`docs/ambiente-homologacao.md`), numa pilha Docker separada. **Nada aqui toca a
produção.**

Decisões do dono, em 24/09/2026 -- a do acesso refeita em 02/10/2026:

| | escolha | consequência |
|---|---|---|
| servidor | BVIP | pilha própria (`franquiabv-teste`), portas próprias, banco próprio |
| dados | cópia **anonimizada** | gerada na sua máquina por `scripts/exportar-base-teste.sh` e levada por `scp` |
| acesso | **senha HTTP** na frente de tudo, e noindex (02/10/2026, D326: "sim, suba como está com senha HTTP"; em 24/09 era "só noindex, sem senha HTTP") | o nginx do host pede a senha antes do ERP e da tela nova; e o script troca a senha de TODOS os colaboradores por uma senha aleatória, com o site fora do ar até a troca |

A troca da senha dos colaboradores vale também por baixo da senha HTTP. A cópia
anonimizada deixa todo colaborador com login `user<id>` e senha `dev123456`,
publicada nestes documentos, e o `user1` é administrador: sem a troca, isso daria
o administrador a quem passasse da senha HTTP. E a senha HTTP ficou ligada em
02/10/2026 porque a cópia leva um CPF e 243 telefones que o anonimizador não
alcança (D326; "O que fica de fora", abaixo).

**A senha HTTP não liga sozinha.** Ela é uma escolha gravada no `.env` do teste
(`SENHA_HTTP=sim` ou `SENHA_HTTP=nao`; escreva exatamente assim -- o script lê o
`.env` como o compose o lê, aspas em volta e comentário no fim saem, e outro
valor reprova), e o `.env` que o `preparar` gera **não tem a linha**. **Desde
08/11/2026 não há padrão** (o D451, a fatia 129): sem a variável na linha de
comando e sem a linha no `.env`, o `publicar`, o `atualizar` e o `voltar` (e o
`_aplicar` deles) **recusam ligar o vhost**, antes de mexer, e dizem os dois
comandos -- o `htpasswd` e o `SENHA_HTTP=sim ... publicar`; e o `conferir` reprova o
site no ar sem senha e sem a linha. Até então o script assumia `nao` e publicava
**aberto**, sem recusar nem avisar: **um clone do BVIP com o script de antes ainda
faz isso** até o `git pull` desta versão. No `publicar`, a recusa vem com o freio
armado: um site no ar sem a escolha sai do ar. O primeiro `publicar` leva
`SENHA_HTTP=sim` ("Subir", abaixo), que grava a escolha no `.env`; os deploys
seguintes a mantêm sem repetir a variável. Antes dele, o arquivo da senha, no
caminho que o vhost espera: `sudo htpasswd -c
/etc/nginx/.htpasswd-sistema.franquiabv.xyz <usuario>` -- o `htpasswd` vem do
pacote `apache2-utils`, e o `-c` cria o arquivo e apaga o que houver nele (para
mais um usuário, sem o `-c`). Com `SENHA_HTTP=sim` e sem esse arquivo, o
`publicar` recusa, e o site fica fora do ar.

Para ligá-la num site que já está no ar sem ela: o arquivo, e `SENHA_HTTP=sim bash
scripts/deploy-teste.sh publicar` -- pelo `publicar`, que não troca o código: o
`atualizar` puxaria o `main`, e depois de um `voltar` traria de volta o código
de que se voltou. **O `publicar` religa o site**: com ele tirado de propósito,
troque (ou acrescente) só a linha `SENHA_HTTP` do `.env`, e o próximo `publicar` a
aplica. `SENHA_HTTP=nao` desliga -- contra a decisão do D326.

**Antes de tudo, se este ambiente já rodou no BVIP** (qualquer versão, qualquer
passo): veja se o site está aberto. Até 08/10/2026 esta página mandava publicar
sem a senha HTTP, e um `publicar` assim sai aberto (D451). No clone que rodou,
`bash scripts/deploy-teste.sh conferir`, que só lê, e a linha do endereço:

- `público: senha HTTP na frente de / e /novo/ (401)` -- fechado; siga. É a
  **única** linha que deixa seguir sem o `tirar`;
- `público: ERP em /, tela nova em /novo/` (o `/` com 200 ou 302), ou as recusas
  `SENHA_HTTP=sim no .env, e o vhost instalado não pede senha` e (desta versão em
  diante) `o site está no ar SEM a senha HTTP, e o .env não tem a linha` -- **aberto**: rode
  `bash scripts/deploy-teste.sh tirar` **na hora**, antes de qualquer outra coisa;
- `público: fora do ar`, `público: MEIO FORA` ou `público: ainda sem
  certificado` -- fora, e o `tirar` também. O `base`, e o freio de um passo que
  não chegou ao fim (um `publicar` que reprovou, por exemplo), tiram o site
  **sem** gravar a marca do `tirar` (`.deploy-teste/tirado`); sem ela, o
  `conferir` segue até "Tudo conferido", e o `atualizar` seguinte religa o site
  com a escolha que estiver no `.env` -- aberto, se lá não há `SENHA_HTTP=sim`
  (D451). O `tirar` grava a marca, e aí só o `publicar` religa. Sem certificado
  o `atualizar` não religa, e o `tirar` ali só não faz mal;
- se o `conferir` parar antes dessa linha (sem sudo, sem `.env`, o cron, o root
  num clone de antes da v9), meça de fora, de qualquer máquina:
  `curl -sS -o /dev/null -w '%{http_code}\n' https://sistema.franquiabv.xyz/` --
  401 é a senha; qualquer outro, o `tirar` já (200 ou 302 é aberto; 000 é que
  não respondeu: sem DNS, sem certificado, ou fora do ar).

Na primeira versão (d391e43), que não tem o `tirar`, e como root num clone de
antes da 9ee7c144 (26/09/2026), cujo `tirar` também recusa o root: os dois
comandos à mão de "Antes da primeira vez". Eles tiram o site, mas não gravam a
marca -- o `tirar` do script novo, depois do `git pull`, a grava.

Na dúvida, o `tirar`: ele só tira do ar, e só o `publicar` religa.

---

## O que o ambiente não pode fazer

O código carregava duas coisas de produção: a **chave do Asaas** (`Asaas.php`,
D3) e o **token das rotinas por URL** (D4). **Desde 03/10/2026 (SEGREDOS) as
duas vêm do `local.php`**, e o deste ambiente não as tem -- o que o
`conferir` ainda não mede: a lista de credenciais que ele exige vazias não tem
as duas, e o arquivo está congelado com a carga (D330). Sem a chave, as
rotinas de faturas lançam antes de falar com o Asaas; sem o token, as rotinas
por URL recusam todo pedido. As camadas abaixo continuam, por dois motivos: o
**valor** do token segue escrito no código pelo `VisagismoController` (exceção
declarada do portão de segredos, e o mesmo valor da produção); e o `voltar`
para um código anterior a 03/10 traz de volta os dois escritos. Com aquele
código, a rotina de royalties chamada daqui **cobraria franqueados de
verdade**, e outras mandam WhatsApp e notificação. Quatro camadas, e o `bash
scripts/deploy-teste.sh conferir` mede cada uma:

1. **O nginx do host nega** (`docker/nginx/teste-host.conf.modelo`), em
   qualquer grafia (`/API/Cronjobs` também) e com ou sem a barra final:
   - as rotinas por URL (`/api/cronjobs`, `/api/marketing`, a de cupons);
   - o **mesmo controlador por outro caminho**: as rotas genéricas
     `/mod-<módulo>/<controlador>/<ação>` aceitam o apelido de qualquer
     controlador registrado, e `cronJob` e `whatsapp` são globais —
     `/mod-cadastros/cronJob/<rotina>` rodava a rotina;
   - o visagismo do aplicativo, e a exportação de vendas por hash da unidade
     (`/api/unidade`);
   - os quatro chatbots públicos (`/rh`, `/ajudantevip`, `/invencivel`,
     `/motivacionalvip`), que são da conta de **produção** do Zapier — a
     conversa sai do navegador de quem abre, e o corte de saída do contêiner
     não a alcança;
   - `/index.php/…` (o mesmo roteamento), `/autenticacao/migrar-senhas` e a
     rota de depuração de uma biblioteca.
2. **O contêiner do PHP não alcança** Asaas, OneSignal (as duas APIs), SMS,
   WhatsApp, Yungas, Zoomtec nem o SMTP de produção: esses nomes resolvem para o
   loopback lá dentro, **no IPv4 e no IPv6** (`127.0.0.1` e `::1`, em
   `docker-compose.teste.yml`). Só o IPv4 não bastava: no ensaio, o
   `getent hosts` devolveu o endereço IPv6 real do onesignal.com. O e-mail vai
   para o Mailpit. **O Firebase** (o FCM e o token do Google, cortados desde
   10/10/2026) **continua cortado do mesmo jeito**, e só se abre quando o dono
   acrescenta o `docker-compose.teste-firebase.yml` ao `COMPOSE_FILE`, para o push
   num aparelho de verdade (a resposta 74, 11/10/2026; ver "O push pelo Firebase
   no teste", abaixo). Os dois nomes do Google saíram de `HOSTS_MORTOS` por isso, e
   moram em `HOSTS_DO_GOOGLE`: o `conferir` os exige no loopback como os outros
   dez enquanto o arquivo não estiver no `COMPOSE_FILE`, e só com ele os tira da
   medida (a verificação de 11/10/2026).
3. **A base chega endurecida e é conferida.** A cópia sai da sua máquina já
   anonimizada e endurecida (abaixo), e o `base` a endurece de novo aqui
   (`scripts/endurecer-base-teste.sql`). Saem:
   - os ids e links de cobrança do Asaas (`royalties_faturas`) e as chaves de
     WhatsApp;
   - os hashes que abrem coisas sem login: os previsíveis que o anonimizador
     deixa (o da unidade abre a exportação de vendas; o do cliente, o
     formulário de fidelidade) e os de arquivos, VIP Class e pesquisas, que
     valem também na produção;
   - os nomes que o próprio sistema escreve em texto ("realizado pelo
     colaborador 12 - <nome>" em 19 mil históricos de pontos, "<nome> cancelou
     em" no estoque, "indicação do <nome>") e o contato dos convênios;
   - o **texto livre** que a equipe digita — motivos de fechamento da agenda,
     de retirada, de cancelamento e de desconto (na venda e **por item**, onde
     o PDV grava: 159 mil), descrições de saída, mensagens, nomes de arquivo —,
     que vira um texto genérico: na cópia de 25/09/2026 são 134 mil motivos de
     fechamento e 62 mil de cancelamento, e é onde a recepção escreve quem
     faltou, adoeceu ou desistiu;
   - o HTML de incorporação dos assistentes, a URL do painel de BI com o
     token (se a coluna existir), as sessões do aplicativo — e a senha de
     todos, trocada. O hash do cliente sai com 8 caracteres, o tamanho que o
     formulário de fidelidade aceita.

   A régua (`scripts/conferir-base-teste.sql`, 21 números) confere isso e o
   **padrão do anonimizador** (o nome de cada cadastro é o "Cliente <id>", o
   "Colaborador <id>" etc.): uma anonimização pela metade não passa mais. O que
   ela NÃO mede: os hashes que são capacidade na URL (arquivos, VIP Class,
   pesquisas) saem regerados ao acaso pelo endurecimento, e não há como
   compará-los, daqui, com os da produção -- quem os garante é o UPDATE.
4. **Nenhum cron.** O `conferir` reprova cron que chame este endereço.

E os cookies só viajam por HTTPS: HSTS no vhost, `session.cookie_secure` no
`docker/php/php.teste.ini`, e o nginx do host marca `Secure` nos outros dois
cookies do ERP (`login` e `token_web`), que o código emite sem ele.

### O site só está no ar depois de tudo conferido

O `base`, o `atualizar` e o `voltar` **tiram o endereço do ar antes de mexer**,
por dois caminhos, e conferem os dois: o contêiner do nginx da pilha é removido
(um contêiner que não existe não volta por política de reinício nenhuma) e o
vhost do host é desligado e recarregado. Se um dos dois não sai — o `nginx -t`
do host reprovado por outro site, por exemplo —, eles **param antes de mexer**.
O endereço só volta no fim, depois das conferências: pelo próprio `atualizar` e
`voltar`, ou pelo `publicar`, depois de um `base`. E quem põe no ar confere
antes também o cron, o selo da base (abaixo) e se o app que roda nasceu da
configuração em disco.

**Para só tirar do ar:** `bash scripts/deploy-teste.sh tirar`. Ele lê do `.env`
só o projeto e o domínio -- o freio não depende do resto da configuração estar
certo, e o que acender no `.env` é dito depois de o site sair (o `.env` é lido
como o compose o lê: aspas em volta e comentário no fim saem). E ele não recusa
antes de frear: a intenção e a metade da pilha vêm antes das guardas -- só o
clone e o nome do servidor são conferidos antes --; sem o projeto no `.env`,
ainda desliga o vhost do host e diz o comando que tira o nginx da pilha; sem
sudo, tira o nginx da pilha e diz que o vhost ficou; com outro `DOMINIO` na
linha de comando (outro nome, maiúsculas), tira o do `.env` (é o deste
ambiente) e diz que ignorou o dado. E ele não
escreve nada antes do freio: não copia a si mesmo para o `/tmp`, e sem a trava
no `$HOME` (disco cheio, só-leitura) tira sem ela. E **fica**: a intenção é
gravada ANTES do freio (`.deploy-teste/tirado`), e vale mesmo que o freio falhe
pela metade -- e, se ela não se grava (disco cheio), o freio roda do mesmo jeito
e o tirar diz que a intenção não ficou. O `atualizar` e o `voltar` trocam o
código e deixam o site fora; só o `publicar` que chega ao fim, conferido, a
apaga.

Um passo que não chega ao fim (erro, Ctrl-C, queda do ssh -- com a saída num
terminal, num `| tee`, ou num cano que morre sem mandar sinal nenhum, como o
`ssh` sem terminal que cai; ou um sinal como o `timeout -s ALRM`, o USR1 de um
gerenciador de processos, o `ulimit -f`) deixa o site fora do
ar -- o `tirar` também, pela metade que já tiver feito; um `base` que não chega ao fim também **apaga a base**, que podia estar com
a senha publicada, e a limpeza não se interrompe com um segundo Ctrl-C. Rode os
passos longos dentro de um `tmux` ou `screen`.

Se o próprio freio falhar -- na queda do ssh, o `sudo` sem terminal pede
senha, e o vhost fica ligado; ou o `tirar` que não pega pelos dois caminhos --,
isso **fica escrito** (`.deploy-teste/talvez-no-ar`, no clone): o `conferir`
reprova até um `tirar`. E se é a limpeza do `base` que falha (o mysql caiu na
carga), a marca é outra (`.deploy-teste/base-nao-apagada`): a base pode ter a
senha publicada, o `conferir` e o `publicar` a cobram, e só um `base` que
termine a apaga.

**A base diz de que código é.** A cópia sai do exportador **selada**: a primeira
linha do dump é o sha256 dos cinco arquivos que lhe dão forma (o anonimizador,
o exportador, o endurecimento e a régua). O `base` só carrega a cópia cujo selo
é o desta árvore, roda o endurecimento e a régua copiados no começo, e grava no
próprio banco o selo **conferido no dump** -- se a árvore mudar durante a carga
(um `git pull` à mão noutra janela), a base é apagada em vez de selada. E o site
só **volta** com esse selo igual ao do código que está no clone -- **medido**, e
não marcado: venha o código pelo `atualizar`, pelo `voltar`, por um `git pull` à
mão ou por um passo interrompido no meio, uma base de outro código não volta ao
ar. A cópia antiga, que fica no `$HOME` com o mesmo nome, é recusada pelo selo.

O selo é medido por quem religa, e **não vigia o site no ar**: um `git pull` à
mão com o site no ar serve o código novo na hora, e o selo só o cobra no
próximo `conferir` ou `publicar`. Não faça isso -- tire antes (`tirar`), ou use
o `atualizar`, que tira. Se já foi feito, o `atualizar` reconhece a árvore que
mudou por fora -- o HEAD que não é o último commit aplicado (`.deploy-teste/historico`);
antes do primeiro `atualizar`, o selo da base que já não é o da árvore --, mede
o site, e manda rodar o `tirar` primeiro. E a base sem selo com o site no ar, que
nenhum `publicar` desta versão faz, também é dita. O esquema é comparado a
partir do último commit aplicado, e não do HEAD: a migração que o pull trouxe
não passa sem o `ACEITAR_ESQUEMA`.

O estado de cada clone -- a senha, o histórico e as marcas do "fora do ar" e da
base -- mora em `.deploy-teste/`, dentro do clone e fora do git: dois clones do mesmo
usuário não leem mais as marcas um do outro.

Só um `deploy-teste.sh` roda por vez **no servidor** (`flock` em
`/var/lock/franquiabv-teste.trava`, desde 08/11/2026: o D444, a fatia 129). Até
então a trava morava no `$HOME` de quem rodava -- `/root/.franquiabv-teste.trava`
para o root --, e uma rodada como root não via a de outro usuário, nem o
contrário, com a recusa dizendo "neste servidor". Agora é um caminho fixo, um
arquivo **do grupo `docker`, modo 640**: o root e quem está no grupo -- quem roda
o deploy roda o `docker compose`, e está nele -- o abrem (só para ler: o arquivo
criado por um não barra o outro, qualquer que seja o umask de quem o criou), e
quem está fora do grupo nem o abre, e por isso não o segura (o `www-data` de outro
vhost não barra o deploy, nem o `tirar`). Um arquivo no lugar dela que não seja
assim (plantado por outro usuário, ou um link) é alheio: o root o apaga e refaz;
outro usuário para e diz o `sudo rm -f`; o `tirar` segue sem a trava, com o
aviso. Quem roda o deploy sem estar no grupo `docker` sai 1, dizendo isso.
Provado num contêiner descartável com o root, um usuário do grupo e um de fora
(`scripts/ensaiar-trava-do-deploy-teste.sh`). Rodar sempre com o mesmo usuário
segue o certo (abaixo, "Antes da primeira vez"), pelo dono dos arquivos do clone.
**Na passagem**, o `atualizar` feito com o script de antes ainda trava o `$HOME`
(o descritor passa ao `_aplicar` da árvore nova): uma rodada do script novo no
meio dele não é barrada. Depois dela, todos travam o mesmo arquivo.

O que continua valendo, e não é deste ambiente resolver: a chave do Asaas
precisa ser trocada (D3) -- saiu do código em 03/10/2026, mas segue no
histórico do git --, e o token das rotinas é o mesmo da produção: saiu do
`CronJobController`, e o mesmo valor segue escrito no `VisagismoController`
(D4).

---

## Antes da primeira vez

**No BVIP:** Docker com o `docker compose` v2, nginx, certbot (`apt install
certbot`), `python3` e `flock` (vêm no Ubuntu), `curl`, `openssl`, `git` e
`realpath` -- o script cobra cada um antes de mexer --, e o clone do
repositório com a chave de deploy somente leitura — o passo a passo está em
`docs/ambiente-homologacao.md`, seção "Antes: acesso ao repositório". Rode o
script como o usuário dono do clone: root também serve, desde 02/10/2026 (v9),
por decisão do dono -- num clone do root, rode sempre como root; com outro
usuário, ele precisa de sudo. Misturar os dois deixa arquivos do clone com
outro dono, e o git recusa o clone de outro dono ("dubious ownership").

E mais dois, que esta página não citava até 08/10/2026:

- o **`sudo`, mesmo para o root**: o script chama `sudo -v` e roda por ele o
  nginx, o certbot, o cron do root e a pasta da tela nova. Sem o comando, param o
  `conferir`, o `base`, o `publicar`, o `atualizar`, o `voltar` e o `_aplicar` (o
  interno dos dois); o `preparar` não o usa; e o `tirar` faz só a metade da pilha
  -- tira o nginx dela, e o vhost do host fica, com o aviso;
- o **`htpasswd`**, do pacote `apache2-utils` (`sudo apt install apache2-utils`),
  para a senha HTTP (no topo desta página).

**Saída para a internet**, do BVIP: o Docker Hub (as imagens `php:8.1-fpm` e
`composer:2.2`, da construção da imagem do PHP; `mysql:8.4`, `nginx:1.27-alpine`,
`axllent/mailpit` e `adminer`; e a `node:22-bookworm-slim`, da build da tela
nova); os espelhos do apt do Debian (a construção da imagem do PHP instala
pacotes: `docker/php/Dockerfile`); o `phar.phpunit.de` (o PHPUnit da mesma
imagem); o registro do npm (o `npm ci` da tela nova); o packagist e o GitHub (o
`composer install`; o `atualizar` também faz `git fetch`); e o Let's Encrypt (o
certbot fala com ele, e o desafio entra pela porta 80). O corte de saída do
teste é só do contêiner do PHP, e só para os hosts das integrações.

**O `~` dos comandos abaixo é o `$HOME` de quem roda** (correção de 07/10/2026:
o texto os dava como um lugar só). Logado como root, `~/franquiabv-teste` é
`/root/franquiabv-teste` e `~/base-teste.sql.gz` é `/root/base-teste.sql.gz`;
o `scp base-teste.sql.gz usuario@bvip:~/` põe a cópia em `/home/usuario`, e o
`base` como root não a acha ali. Mande a cópia para o `$HOME` de quem vai rodar
o `base` (`root@bvip:~/`, se for o root), ou dê ao `base` o caminho inteiro.

**Se o BVIP chegou a rodar uma versão anterior deste script:** primeiro o "Antes
de tudo", no topo desta página. Depois:

- **Reaproveite o clone que rodou.** O projeto vem fixo do `.env.teste.example`
  (`COMPOSE_PROJECT_NAME=franquiabv-teste`): um segundo clone, com o `preparar`,
  assume os mesmos contêineres e o mesmo volume do banco, e não lê as marcas do
  primeiro (`.deploy-teste/` é por clone). Se quiser mesmo um clone novo: no
  antigo, `bash scripts/deploy-teste.sh tirar` e `docker compose down -v` -- que
  APAGA o banco de teste deste projeto (a cópia volta pelo `base` do clone novo).
  Só com o `docker compose down`, sem o `-v`, o volume fica com a senha do banco
  do `.env` antigo, que o `.env` novo do `preparar` não tem, e o clone novo para
  no `preparar`, que imprime sem acento "a aplicacao nao entra no banco com o
  usuario dela: ..." e, embaixo, "a configuração do teste não está segura
  (acima).".
- **A cópia de 02/10/2026 serve** (selo `fca02f5a26f7…`, conferido em 08/10/2026
  contra o desta árvore): os cinco arquivos do selo não mudam desde 26/09/2026
  (ffbe670), e a `VERSAO_DA_CARGA` segue 1 -- não é preciso exportar de novo. A
  base que está lá pode não ter selo, ou ter o de outro código; essa, esta versão
  não põe no ar.
- **O site sai do ar ANTES de o código chegar**, em qualquer versão: o `git pull`
  troca o PHP que o endereço serve na hora. A primeira versão (commit d391e43) não
  tem nem o `tirar`, e o vhost dela não nega a exportação de vendas, as rotinas
  pelo caminho genérico nem os chatbots: tire à mão. As outras têm o `tirar`.
- **Como root, num clone de antes da v9** (anterior a 6c2c807, 02/10/2026), o
  `conferir` e o `atualizar` de lá recusam o root ("rode como o usuário dono do
  clone, e não como root"). Na 9ee7c144 (26/09/2026, a v8) só o `tirar` roda;
  antes dela (de 6bcfe44 a ffbe670), nem ele -- as guardas recusam o root em todo
  subcomando --, e o site sai pelos dois comandos à mão da primeira versão
  (abaixo). Depois, o `git pull --ff-only` e o resto pelo script novo -- o
  segundo caminho abaixo, que roda o `tirar` de novo e grava a marca.

O `conferir` diz qual dos dois caminhos abaixo vale.

**A base de 02/10 carregada até o fim** (o `conferir` chega a "Tudo conferido"
com a linha `base (uso): <n> colaboradores, ...`, e não `base: vazia` -- rode-o
de novo depois do `tirar` do "Antes de tudo"): o `atualizar`, que tira o site
antes da troca. Antes dele, a escolha da senha no `.env`:

```bash
grep '^SENHA_HTTP=' .env | tail -n 1     # tem de imprimir: SENHA_HTTP=sim
ACEITAR_ESQUEMA=sim bash scripts/deploy-teste.sh atualizar
```

**O `atualizar` religa o site com a escolha do `.env`**, e, com o script de antes
de 08/11/2026, sem a linha religava **aberto** (D451); o desta versão recusa,
antes de mexer. Mas é o script que ESTÁ no clone que roda o `atualizar`: se o
`grep` não imprimir `SENHA_HTTP=sim` (a d391e43 não gravava a escolha, e o `.env`
do `preparar` nasce sem ela), rode o `tirar` antes do `atualizar`: com a marca,
ele troca o código e deixa o site fora.

O `ACEITAR_ESQUEMA=sim` é o certo, sem ALTER nenhum ("Atualizar, voltar,
conferir", abaixo), e o `ACEITAR_BASE` não é preciso: o selo não mudou. **Menos
desde as fatias de 03/11/2026** (o D726): depois do `atualizar`, as migrações delas e a
conferência do esquema, em "Atualizar, voltar, conferir" -- sem elas, os relatórios, o PDV
e o caixa respondem 500. Com o
site tirado (pelo "Antes de tudo", ou pelo `tirar` acima), ou ainda sem
certificado, o `atualizar` troca o código e o deixa fora: falta o arquivo da
senha HTTP, se ainda não existe, e `SENHA_HTTP=sim bash scripts/deploy-teste.sh
publicar` (com o `EMAIL_CERTBOT`, sem certificado), que grava a escolha no
`.env`. Só com o 401 do "Antes de tudo" e o `SENHA_HTTP=sim` no `.env` ele mesmo
religa o site -- com a senha.

**O `base` não chegou ao fim, ou nunca rodou** (o `conferir` diz `base: vazia`,
"não tem a tabela usuarios", "não tem selo" ou "a última carga não chegou ao
fim"): o `git pull`, o `base` e o `publicar`, com a senha.

```bash
# PRIMEIRO, com o que está lá -- se for a primeira versão (d391e43):
sudo rm -f /etc/nginx/sites-enabled/sistema.franquiabv.xyz.conf && sudo systemctl reload nginx
docker compose rm -s -f nginx
# ou, em qualquer versão depois dela (como root, só da 9ee7c144 em diante):
bash scripts/deploy-teste.sh tirar

git pull --ff-only
bash scripts/deploy-teste.sh tirar          # o desta versão: confere os dois caminhos
sudo htpasswd -c /etc/nginx/.htpasswd-sistema.franquiabv.xyz <usuario>   # se ainda não existe
bash scripts/deploy-teste.sh base ~/base-teste.sql.gz    # a cópia exportada por ESTE código
SENHA_HTTP=sim bash scripts/deploy-teste.sh publicar     # EMAIL_CERTBOT=... na primeira vez
bash scripts/deploy-teste.sh conferir       # tem de dizer: senha HTTP na frente de / e /novo/ (401)
```

Pelo `atualizar`, neste caso, ele recusa sem `ACEITAR_BASE=sim` (a base não tem o
selo do código novo), e as duas variáveis vão juntas, porque o esquema é medido
antes da base: `ACEITAR_BASE=sim ACEITAR_ESQUEMA=sim bash scripts/deploy-teste.sh
atualizar`, e depois o `base` e o `SENHA_HTTP=sim bash scripts/deploy-teste.sh
publicar` do bloco acima. Mas, depois de um `base` que morreu no meio da carga, o
banco ficou vazio, sem tabela nenhuma, e o resto do `atualizar` para em "não tem
a tabela usuarios" -- com o código já trocado e o site fora: siga com o `base` e
o mesmo `SENHA_HTTP=sim ... publicar`. Por isso o caminho acima, pelo `git pull`.

**O `publicar` que o script imprime** vem com a senha enquanto o `.env` não tem a
linha, desde 08/11/2026 (o D451): os "Próximos passos" do `preparar`, o fim do
`tirar`, o `atualizar` que deixa o site fora e as recusas do selo dizem o
`htpasswd` e o `SENHA_HTTP=sim ... publicar` -- e o fim do `base` também (só o
texto que ele imprime depois da carga mudou; a carga e o selo, não). E o
`publicar` curto, digitado num `.env` sem a linha, recusa e diz os dois comandos.
**Com o script de antes**, todas essas frases
mandavam o `publicar` sem a senha, e ele saía aberto: não as copie num clone que
não tenha esta versão. O primeiro `publicar` é o desta página, com
`SENHA_HTTP=sim`; depois dele a escolha está no `.env`, e os do script servem.

O `base` reinicia o app, que relê o `php.teste.ini` (o `git pull` troca o
arquivo, e o contêiner seguia montando o antigo). A senha e o histórico que a
versão anterior guardava no `$HOME` vêm sozinhos para `.deploy-teste/`.

**No DNS e na rede:** um registro A de `sistema.franquiabv.xyz` apontando para o
IP do BVIP, e as portas 80 e 443 do BVIP chegando ao nginx do host (o desafio do
certificado entra pela 80). O certificado só sai com os dois; sem o registro, o
`publicar` para em "não resolve".

**Na sua máquina — a cópia anonimizada.** Só o artefato conferido atravessa a
rede; o dump cru nunca sai da sua máquina.

```bash
docker compose up -d                        # o MySQL local precisa estar no ar
bash scripts/exportar-base-teste.sh         # lê franquiabv_dev; grava base-teste.sql.gz
scp base-teste.sql.gz usuario@bvip:~/
```

O `exportar-base-teste.sh` **só lê** a base de origem. Ele a copia para uma
cópia de trabalho com nome próprio (`franquiabv_exportacao_<pid>`), confere que
ela tem todas as tabelas, passa a cópia pelo anonimizador e pelo endurecimento,
e só exporta se a régua passar. O dump sai **selado** (a primeira linha diz o
selo) para um `.parcial`, que só ganha o nome final completo (`-- Dump
completed` no fim) e íntegro. Ele recusa exportar com mudança não commitada no
anonimizador, no exportador, no endurecimento ou na régua: o selo não seria o
de commit nenhum, e o servidor recusaria a cópia -- exporte com o código que o
servidor tem. E o que roda é o que foi selado: o anonimizador e o modelo dele, o
endurecimento e a régua saem de cópias tiradas no começo (o próprio exportador
roda de uma cópia de si), e o selo é o dessas cópias. Um checkout no meio de uma
exportação de uma hora não troca o código que dá forma à cópia; e, se a árvore
não estiver igual no fim, nada sai. Ele avisa quando
acha cópias de trabalho de exportações que morreram no meio: cada uma é uma
cópia inteira da base, e até o anonimizador passar tem o dado real. A cópia de trabalho
é apagada no fim, tenha dado certo ou não. Outra origem: `bash
scripts/exportar-base-teste.sh ORIGEM SAIDA.sql.gz`. O `.gitignore` recusa
`*.sql.gz`.

**Por que o anonimizador roda de novo, mesmo numa base já anonimizada:** até
25/09/2026 ele não refazia a coluna `tags` de `clientes` e `unidades_socios`,
que o sistema monta com o nome, o telefone e o e-mail para a busca — a base
local anonimizada tinha ali o dado real de 241.683 dos 242.152 clientes —, nem
os nomes que o sistema escreve em texto. O anonimizador agora faz as duas
coisas e tem cinco contadores, mas a `franquiabv_dev` local anonimizada antes
disso segue com o dado real até ser anonimizada de novo (D225). A exportação
não depende disso: a cópia passa pelo anonimizador corrigido.

---

## Subir

Num servidor que já rodou alguma versão, antes o "Antes de tudo" (no topo) e o
caminho de "Antes da primeira vez". Num clone novo, dentro de um `tmux`, como o
dono do clone:

```bash
cd ~/franquiabv-teste               # o clone, no BVIP (o ~ é o $HOME de quem roda: ver acima)

bash scripts/deploy-teste.sh preparar
sudo htpasswd -c /etc/nginx/.htpasswd-sistema.franquiabv.xyz <usuario>   # a senha HTTP (D326)
bash scripts/deploy-teste.sh base ~/base-teste.sql.gz
SENHA_HTTP=sim EMAIL_CERTBOT=voce@exemplo.com bash scripts/deploy-teste.sh publicar
bash scripts/deploy-teste.sh conferir    # tem de dizer: senha HTTP na frente de / e /novo/ (401)
```

**Sem o `SENHA_HTTP=sim`, o `publicar` recusa** desde 08/11/2026 (D451; até
então saía aberto): o `.env` do `preparar` não tem a linha, e não há padrão. Com
ele e sem o arquivo do
`htpasswd`, o `publicar` recusa -- depois da build da tela nova e do
certificado, com o site fora do ar. A escolha fica no `.env`, e os deploys
seguintes a mantêm. O `publicar` que o `preparar` e o `base` imprimem no fim já
vem com a senha enquanto o `.env` não tem a linha (D451; "Antes da primeira vez").

**Na primeira vez, o certbot pergunta**, no meio do `publicar` (depois da build
da tela nova): os termos do Let's Encrypt e, sem o `EMAIL_CERTBOT`, o e-mail -- e
pode perguntar se o e-mail vai para a EFF. O script não passa
`--non-interactive` nem `--agree-tos`, e o `CONFIRMAR=sim` responde só às
perguntas do script. Fique no terminal para responder: o certbot espera; rodado
sem terminal, falha, e o `publicar` para com o site fora do ar, dizendo "o
certbot não emitiu o certificado (DNS apontando para cá? porta 80 aberta?)" -- e
a causa pode ser só a pergunta. Se o certbot do servidor já tem conta registrada,
não pergunta.

**Antes de publicar um commit que mudou o `frontend/`, rode aqui o
`scripts/ensaiar-build-do-publicar.sh`** -- numa máquina com o repositório e o
docker, e não no BVIP --, com o commit que vai subir:
`sh scripts/ensaiar-build-do-publicar.sh <commit>`. O "mudou" é contra a build
instalada no BVIP, e não contra o commit anterior: o deploy reconstrói quando o
`frontend/` do commit difere do da build que está lá (o commit dela fica em
`/var/www/sistema.franquiabv.xyz/front/novo.commit`; daqui,
`git diff --name-only <esse commit> <o que vai subir> -- frontend/`), e um
`atualizar` que salta vários commits reconstrói mesmo que o último não toque o
`frontend/`. Rode também antes do primeiro `publicar` e antes de **todo**
`voltar`, que reconstrói sempre a tela nova, com o `frontend/` do commit alvo.
Os três constroem a tela nova com o freio armado, e uma build que falha tira o
site do ar. O ensaio faz a mesma build (a imagem, as variáveis e o comando
lidos do `construir_front`, que ele confere inteiro contra um molde, e o
`-u "$(id -u):$(id -g)"` de quem roda: no BVIP, como root, é `0:0`, e
`USUARIO_DO_DEPLOY=0:0` o ensaia) num volume do docker, que distingue
maiúsculas como o disco do servidor -- a pasta do Windows não distingue --,
confere o `dist` e o serve num `nginx:1.24` com o vhost gerado do modelo pelo
mesmo `sed` do `instalar_vhost`, com e sem a senha HTTP. Sai com 0 (verde), 1
(defeito) ou 2 (não conseguiu medir, e nada falhou antes). Dos defeitos, a
build ou o `nginx -t` reprovados, o `/novo/` sem a tela nova, o noindex ou o
HSTS faltando, ou o 401 faltando com a senha tiram o site do ar no BVIP; o
resto (o tipo, o cache, o 301 do `/novo`, o 404 do asset que falta, a rota
interna) o `conferir` do deploy não mede, e a tela nova fica no ar servida
errada. Baixa os pacotes do npm, e nada mais: por isso fica fora do corredor e
do CI.

**Medido em 09/10/2026, no 2d3c6a0: verde.** A build passou de 35 a 53 s em
oito rodadas (a `node:22-bookworm-slim` do cache desta máquina, com o node
22.23.3 e o npm 10.9.9; 94 pacotes), uma delas como root
(`USUARIO_DO_DEPLOY=0:0`, 44 s), e o `nginx -t` dos dois vhosts também, no
1.24 e no 1.30 (os dois ramos do http2 do `instalar_vhost`); `/novo/` dá o index,
e o JS, o CSS e o favicon que ele cita dão 200 com o tipo certo; o asset que
falta dá 404, e não o index; `/novo` dá 301 para `/novo/`; e `/novo/agenda` dá
404 -- o modelo não tem fallback, e nem o `src` nem o JS do `dist` põem rota no
caminho. Com a senha, 401 sem ela e 200 com ela. A imagem que o BVIP baixar pode
ser outra (a etiqueta anda), a duração da build lá não foi medida, e as
permissões dos pais da raiz servida no BVIP também não (aqui o docker os cria).

| passo | o que faz |
|---|---|
| `preparar` | gera o `.env` (projeto `franquiabv-teste`, portas no loopback, senhas aleatórias, o domínio) e o `local.php` (`sistema.url` do teste, e-mail no Mailpit); tira o nginx da pilha; sobe o resto; instala as dependências; cria as pastas graváveis; passa o `verificar-local-php.php`; confere a configuração, os hosts de saída e a base. **Não sobe o nginx**: quem o sobe é o `publicar` |
| `base ARQ` | confere o dump (a última linha é o `-- Dump completed`; sem `USE`/`CREATE DATABASE`; nenhum e-mail que não seja `.invalid` em clientes, colaboradores e sócios, em qualquer lugar do campo); **tira o site do ar**; recria o banco de teste vazio e carrega; endurece; troca a senha de todos por uma aleatória; derruba as sessões abertas; confere a base pela régua; grava no banco o selo da base; guarda a senha em `.deploy-teste/senha`, no clone (modo 600). **O site fica fora do ar**: quem o religa é o `publicar`. Antes de tudo, recusa a cópia sem o selo desta árvore; e, se a carga não chegar ao fim, a base é apagada |
| `publicar` | confere que o domínio é o do `.env` (antes de tudo: com o de outro site, o freio desligaria o vhost dele); já armado para tirar o site do ar se algo reprovar, constrói a tela nova num contêiner Node (o servidor não precisa de Node) sem tocar o que é servido; **tira o site do ar**; recria o app que divergiu do compose e o reinicia; confere a configuração, os hosts de saída e a base; instala a tela nova em `/var/www/sistema.franquiabv.xyz/front/novo`; tira o certificado, ou o renova se ele vence em menos de uma semana (com o site tirado por meses, a renovação automática falha; na primeira vez, o certbot pergunta: acima); confere o cron, o selo da base e o app; sobe o nginx da pilha (sem mexer no app: `--no-deps`), liga o vhost — com a senha HTTP se a escolha é `sim`, e aí recusa sem o `/etc/nginx/.htpasswd-<domínio>`; sem escolha nenhuma, aberto (D451) — e sonda o endereço público (com a senha ligada, só o 401 de `/` e `/novo/`) — se a sonda reprovar, o site sai do ar de novo. Só no fim, conferido, apaga a marca do `tirar` |

Para trocar a base depois: `base` e `publicar`, nessa ordem.

Depois: **https://sistema.franquiabv.xyz/** é o ERP, e
**https://sistema.franquiabv.xyz/novo/** é a tela nova. Os dois na mesma origem,
de propósito: a `/adm/api` autentica pela sessão do ERP, e o cookie vale só para
o host que o emitiu. Entre no ERP primeiro, e abra `/novo/` depois.

**Confira no navegador, à mão.** Com a senha HTTP ligada, o `publicar` e o
`conferir` só medem o 401 de `/` e `/novo/`: a sonda do index e do arquivo da
tela nova só roda sem a senha, e o "Publicado" não prova que a tela nova
carrega. Primeiro a senha HTTP; depois o `user1`, com a senha que o `base`
gravou em `.deploy-teste/senha`; e então o `/novo/`.

## Atualizar, voltar, conferir

```bash
bash scripts/deploy-teste.sh atualizar          # puxa o main, aplica, confere, religa
bash scripts/deploy-teste.sh voltar <commit>    # volta; os anteriores estão em .deploy-teste/historico
bash scripts/deploy-teste.sh conferir           # só confere
```

O `atualizar` e o `voltar` **tiram o site do ar antes da troca** e o religam só
no fim: o código novo responde pelo volume no instante do merge, e a base, a
configuração e as sondas só são conferidas depois. Depois da troca, o resto do
deploy roda com o script **da árvore nova** (o subcomando interno `_aplicar`):
conferir o commit novo com as sondas do anterior deixava passar o que ele
acrescentou.

**Os dois religam com a escolha da senha HTTP que está no `.env`**, como o
`publicar`: sem a linha `SENHA_HTTP` nenhuma, desde 08/11/2026 recusam antes de
mexer; com o script de antes, religavam **aberto** (D451). Antes do
primeiro `atualizar` ou `voltar` de um servidor que já rodou, `grep
'^SENHA_HTTP=' .env | tail -n 1` tem de imprimir `SENHA_HTTP=sim`; se não
imprime, o `tirar` antes, e o `SENHA_HTTP=sim bash scripts/deploy-teste.sh
publicar` depois ("Antes da primeira vez").

Os dois **recusam**, antes de mexer:

- mudança de esquema (`mysql.sql`, `scripts/migracao-*.sql`, `sql/`): não há
  rodador de migração, e o `mysql.sql` só carrega num volume novo. Aplique o
  ALTER à mão no banco de teste, ou recarregue a base, e rode com
  `ACEITAR_ESQUEMA=sim`. **No primeiro `atualizar` depois de 03/10/2026 a recusa
  vem, e `ACEITAR_ESQUEMA=sim` é o certo, sem ALTER nenhum:** as três migrações
  novas do passo 0 do corte (`migracao-clientes-sessoes.sql`,
  `migracao-pontuacao-resgate-autorizado.sql`, `migracao-unidade-hash-anterior.sql`,
  D323) escrevem o que já existe aqui — a base do teste veio da `franquiabv_dev`,
  que tem a tabela, as quatro colunas, os índices e a chave estrangeira delas
  (conferido no `information_schema` em 03/10). **E de novo no primeiro
  `atualizar` depois de 09/10/2026**, pela migração do índice de `agendas.id_pai`
  (`migracao-agendas-indice-id-pai.sql`) e o `KEY` dele no `mysql.sql` (D220):
  `ACEITAR_ESQUEMA=sim` continua certo sem ALTER, mas desta vez a base do teste
  **não** tem o que a migração escreve -- a `franquiabv_dev` de onde ela veio não
  tinha o índice quando a cópia foi exportada (conferido no `information_schema` em
  09/10). Nenhum código depende dele: sem ele, o Desmarcar do teste segue lendo a
  agenda inteira, como hoje. **A cópia local o tem desde a noite de 09/10/2026**
  (pergunta 62 do lote, a opção (b): nos dois bancos dela; D220), e a próxima cópia
  exportada daqui o leva ao BVIP; a base do teste de lá fica sem ele até essa cópia,
  porque a opção (c), criá-lo também nela, não foi escolhida. O cabeçalho da migração
  mudou na mesma noite, só no comentário, e a recusa olha o nome do arquivo: um
  `atualizar` que parte de um commit já posterior ao `2d3c6a0` recusa de novo, e
  `ACEITAR_ESQUEMA=sim` continua o certo, sem ALTER. **E no primeiro `atualizar`
  depois de 10/10/2026**, pela migração dos aparelhos do push
  (`migracao-clientes-aparelhos.sql` e a tabela dela no `mysql.sql`, D461): o
  `ACEITAR_ESQUEMA=sim` e, **depois** do `atualizar`, a migração rodada à mão,
  porque desta vez a base do teste **não** tem a tabela -- e de novo depois de
  todo `base`, que recria o banco sem ela; ver "A tabela dos aparelhos do push",
  abaixo. **E no primeiro `atualizar` depois de 11/10/2026**, pela migração dos
  cupons (`migracao-cupons-clientes-desconto-produto.sql` e as duas colunas dela no
  `mysql.sql`, D271, o commit `2408c28`): `ACEITAR_ESQUEMA=sim`, **sem ALTER
  nenhum**. A base do teste **não** tem `cupons_clientes.desconto` nem
  `produto_oferecido` -- a `franquiabv_dev` só as ganhou em 12/10/2026, depois da
  cópia exportada --, e pela resposta 83 do dono (a opção (a)) ela as recebe na
  próxima cópia exportada daqui, pela mão dele, e não por um `ALTER` lá. Até essa
  cópia, no servidor de teste o cupom segue a campanha, **sem erro**: o código
  pergunta ao banco se as colunas existem (`ISBase\Doctrine\ColunaNoBanco`, o
  D473). O cabeçalho da migração mudou em 12/10/2026, só no comentário (saiu o "NAO
  rode na copia local"), e a recusa olha o nome do arquivo: um `atualizar` que parte
  de um commit já posterior ao `2408c28` recusa de novo, e `ACEITAR_ESQUEMA=sim`
  continua o certo, sem ALTER. **E no primeiro `atualizar` depois de 12/10/2026**,
  pela migração do "Não" do PDV antigo (`migracao-vendas-produtos-pacote-recusado.sql`
  e a coluna dela no `mysql.sql`, D453): `ACEITAR_ESQUEMA=sim`, **sem ALTER nenhum**,
  pelo mesmo motivo do cupom. A base do teste **não** tem
  `vendas_produtos.pacote_recusado` e a recebe na próxima cópia exportada daqui; até
  lá, no servidor de teste, o "Usar pacote contratado: Não" vale só para o
  lançamento, como antes, **sem erro** -- o código pergunta se a coluna existe (a
  `ColunaNoBanco`, o D473). **E no primeiro `atualizar` depois de 13/10/2026**, pela
  migração dos motivos e dos autores de Contas a Pagar
  (`migracao-saidas-motivos-e-autores.sql` e as oito colunas dela no `mysql.sql`, D205, D207
  e D199): `ACEITAR_ESQUEMA=sim`, **sem ALTER nenhum**, pelo mesmo motivo. A base do teste
  **não** tem as colunas e as recebe na próxima cópia exportada daqui; até lá, no servidor
  de teste, o estorno e o "Quitar" sem pagamento pedem o motivo e acontecem sem guardá-lo,
  o estorno e as exclusões acontecem sem guardar o autor (as exclusões não pedem motivo),
  e o estorno reabre a quitação com motivo, como antes,
  **sem erro** (a `ColunaNoBanco`, o D473). **E no primeiro `atualizar` depois de 16/10/2026**, pela migração do registro
  das devoluções de crédito (`migracao-clientes-creditos-devolucoes.sql` e a tabela
  `clientes_creditos_devolucoes` no `mysql.sql`, D179, a resposta 89 (b), o commit `bc45269`):
  `ACEITAR_ESQUEMA=sim`, **sem ALTER nenhum**, pelo mesmo motivo. A base do teste, a cópia de
  14/10/2026, **não** tem a tabela e a recebe na próxima cópia exportada daqui; até lá, no
  servidor de teste, a devolução de crédito sai do caixa como retirada, com o motivo e o
  cliente na retirada, e **não** grava a linha do registro nem aparece no extrato do
  crédito, **sem erro** (o `RegistroDaDevolucao` pergunta se a tabela existe, pela
  `ColunaNoBanco`, o D473). **E de novo no primeiro `atualizar` depois da fatia 5e (16/10/2026)**,
  pelas duas colunas de quem desfez a devolução e quando (`desfeita_por` e `desfeita_em`, D500,
  a resposta 231 (a)): a mesma `migracao-clientes-creditos-devolucoes.sql` as ganhou no `CREATE
  TABLE` e num `ALTER` novo, idempotente, e o `mysql.sql` também. Um `atualizar` que parte de um
  commit já posterior ao `bc45269` (o `54f99cd`, o `65c8107` ou o `f0e0225`) recusa de novo, e
  `ACEITAR_ESQUEMA=sim` continua o certo, **sem ALTER nenhum**, pelo mesmo motivo: a base do
  teste recebe a tabela, já com as duas colunas, na próxima cópia exportada daqui. Até lá, no
  servidor de teste, cancelar no caixa a retirada de uma devolução de crédito é o cancelamento
  de antes -- o dinheiro volta ao saldo do caixa, e o crédito do cliente **continua baixado** --,
  e o extrato não fala de devolução desfeita, **sem erro** (o `RegistroDaDevolucao` pergunta
  pelas duas colunas, pela `ColunaNoBanco`). A devolução maior que o saldo do caixa (a 232 (c),
  D501) não depende de esquema: no teste ela já sai, e o caixa fica negativo. **E de novo no
  primeiro `atualizar` depois da fatia 11 (17/10/2026, o commit `788c1e7`)**, pelas duas chaves
  únicas dos privilégios no `mysql.sql` (a das pessoas e a do molde do grupo; D289, D374, a
  resposta 125 (a)): `ACEITAR_ESQUEMA=sim`, **sem ALTER nenhum**. A base do teste, a cópia de
  14/10/2026, ainda tem as 117 pessoas com a linha gêmea, e a `migracao-privilegios-unicos.sql`
  é do passo 3 do corte, e não do teste. Até a próxima cópia exportada daqui, que já sai com as
  gêmeas juntadas e com as chaves, o servidor de teste roda como a produção antes do passo 3: a
  trava das escritas impede a gêmea nova, e as gêmeas antigas somam como sempre, **sem erro**.
  **DESDE AS FATIAS DE 03/11/2026, "SEM ALTER" DEIXOU DE VALER** (o D726, achado pelo dono em
  05/11/2026, no deploy depois da fatia 84: o relatório de caixa, o de descontos, o de
  produtos/serviços e outros respondiam "A API respondeu 500 em text/html"). Até ali, toda
  coluna nova que a base do teste não tinha era lida pela `ColunaNoBanco` ou nem era lida; nas
  fatias de 03/11/2026 as entidades passaram a **mapear** as colunas novas -- `Caixa` (`caixas.valor_contado`),
  `Venda` (`vendas.cancelado_em`, `cancelado_por`), `VendaProduto`
  (`vendas_produtos.custo_unitario`, `desconto_lancado_por`, `vendedor_do_pacote`,
  `cota_do_vendedor`), `Produto` (`produtos.cota_parte_pacote`), `ProdutoEstoque`
  (`produtos_estoque.motivo`) e `Indicacao` (`indicacoes.porta`) --, e num banco sem elas toda
  consulta que carrega a entidade dá "Unknown column", que o PHP devolve como a página de erro em
  HTML: os relatórios, e também o PDV e o caixa. **Depois do `atualizar` com
  `ACEITAR_ESQUEMA=sim`, rode as sete migrações destas fatias** (idempotentes: cada `ALTER` só
  roda se a coluna ou o índice ainda não existe; só acrescentam), no diretório do clone do teste,
  com o `DB_NAME` lido como em "A tabela dos aparelhos do push", abaixo:

  ```bash
  for m in caixa-valor-contado custo-do-item venda-cancelamento cota-do-pacote produtos-estoque-motivo indicacao-porta vendas-indice-cliente-status-data; do echo "== $m"; docker compose exec -T -e MYSQL_PWD=root mysql mysql -uroot --default-character-set=utf8mb4 "$DB_NAME" < scripts/migracao-$m.sql || break; done
  ```

  E **confira** que o banco tem tudo o que o código mapeia -- só lê, e tem de sair vazio (na
  cópia local, sai vazio desde 05/11/2026); cada linha é uma coluna ou tabela mapeada que o
  banco do teste não tem, e a migração dela (`grep -l <coluna> scripts/migracao-*.sql`) é a
  que falta rodar:

  ```bash
  docker compose exec -T app php vendor/bin/doctrine-module orm:schema-tool:update --dump-sql | grep -iE "^(ALTER TABLE [a-z_]+ ADD [a-z_]+ |CREATE TABLE)" | grep -viE " ADD (CONSTRAINT|INDEX|UNIQUE|PRIMARY)"
  ```

  Rode essa conferência **depois de todo `atualizar` que recusar pelo esquema, e depois de todo
  `base`**: o `ACEITAR_ESQUEMA=sim` só diz que você decidiu, e não aplica nada. A regra para quem
  escreve migração nova: se a entidade mapeia a coluna, a migração tem de rodar no teste; se ela
  só é lida pela `ColunaNoBanco`, pode esperar a próxima cópia exportada. **E no primeiro
  `atualizar` depois da fatia 85 (06/11/2026)**, pela chave única das conclusões da VIP Class no
  `mysql.sql` e pela `migracao-vipclass-conclusao-unica.sql` (D372, a resposta 213 (a)):
  `ACEITAR_ESQUEMA=sim`, e **NÃO rode essa migração no teste** -- ela APAGA as conclusões repetidas
  e é do passo 3 do corte, como a dos privilégios e a dos cupons; a conferência acima não a pede
  (filtra o `UNIQUE`). A base do teste segue com os pares repetidos, e o código funciona com e sem
  a chave: as telas mostram a primeira conclusão, e o envio relê sob a trava e recusa a segunda;
- o selo da base carregada diferente do que o código novo pede (mudou o
  anonimizador, o exportador, o endurecimento, a régua ou a carga): a base
  carregada não recebe isso sozinha. Gere uma cópia nova na sua máquina com o
  código novo, e rode com `ACEITAR_BASE=sim` -- o código entra e o site fica
  fora do ar --, depois `base` e `publicar` (com `SENHA_HTTP=sim` enquanto o
  `.env` não tem a linha: D451). A pergunta é só a escolha; a garantia é o selo
  medido de novo antes de pôr no ar. A base **sem** selo cai aqui também, e sem
  cópia nova (a de 02/10/2026 serve): a carregada por uma versão anterior do
  deploy, e a de um `base` que nunca rodou (`base: vazia`), passam pelo
  `ACEITAR_BASE=sim`. Mas um `base` que morreu no meio da carga apagou o banco,
  tabelas e tudo, e o `ACEITAR_BASE=sim atualizar` para em "não tem a tabela
  usuarios", com o código já trocado: aí, o caminho pelo `git pull` de "Antes da
  primeira vez".
- a mudança versionada neste clone -- o servidor não é lugar de editar --, **menos o
  `public/app.json`** (desde 08/11/2026, o D549): a tela da versão do aplicativo, a
  antiga e a nova, grava esse arquivo, que é versionado, e publicar uma versão no
  servidor de teste travava o deploy seguinte. Ele fica com a mudança enquanto o
  commit alvo não o toca (o `merge --ff-only` e o `checkout` a levam junto); se o
  alvo o muda, os dois recusam **antes** de tirar o site, e dizem como guardar e
  devolver o arquivo (`git checkout -- public/app.json`). A conferência do composer,
  no meio do `_aplicar`, também o deixa passar.

O `voltar` recusa também commit anterior a este deploy (sem o compose de teste,
o `php.teste.ini`, o modelo do vhost, o endurecimento, a régua, este script ou o
`_aplicar`). E os dois recusam o commit cujo script é de uma versão do deploy
anterior à mínima (`VERSAO_MINIMA_DO_ALVO`, hoje 9; a do script é a `VERSAO_DO_DEPLOY`, hoje 9 -- a v9 aceita root, e a v8 de lá o recusaria no meio do `voltar`, com o site já fora): é o script **de lá** que decide
se o site volta, e o de uma versão anterior religaria sem as garantias desta --
os anteriores a b908498 não medem o selo, e os da v5 e da v6 aceitariam, por sua
vez, um `voltar` seguinte para a v4. Como o script de lá confere o mesmo, a
recusa vale também para o `voltar` feito a partir dele. Para desfazer uma
mudança do aplicativo, reverta-a num commit novo e rode o `atualizar`. Quando as camadas de proteção do commit alvo são diferentes das
atuais — os três compose, os dois nginx, o `php.teste.ini`, o `global.php`, o
endurecimento e a régua —, ele mostra a diferença e pede confirmação.

O script roda de uma cópia de si mesmo: o `git merge` do `atualizar` reescreve o
arquivo no meio da execução, e o bash o lê aos poucos.

O `conferir` só lê — não grava nem a escolha da senha HTTP, e **não tira o
site do ar**: o que acender fica no ar até um `tirar`, e toda reprovação dele
diz isso. Nesta ordem:

- antes de tudo, até do `.env`, a marca de um passo anterior que não conseguiu
  tirar o site do ar, e a da base que um `base` não conseguiu apagar;
- o `.env` e o compose: o projeto, **cada** porta publicada no loopback (lido do JSON: um
  `ports: ["3306"]` publicava em 0.0.0.0), o corte de saída e o nginx sem
  `restart: always`;
- o cron: os crontabs de todos os usuários, `/etc/cron*` e as unidades do
  systemd -- as do sistema, as de usuário (`~/.config/systemd/user`,
  `~/.local/share/systemd/user`, `/etc/xdg/systemd/user`) e as transitórias do
  `systemd-run`, com e sem `--user` (`/run/systemd/transient` e
  `/run/user/<uid>/systemd`);
- o endereço público, **medido** e não deduzido do link em disco. Se ele não
  responde, só está fora com prova: nenhum nginx na pilha, e nada do domínio no
  nginx do host -- um certificado vencido ou um tempo esgotado também não
  respondem, e aí o `conferir` reprova, dizendo o que viu: a pilha com nginx, o
  domínio no nginx do host, ou "não consegui medir" (o `nginx -T` reprovado pela
  configuração de outro site). Se responde 502 sem o nginx da pilha, está meio
  fora (o vhost ligado sem a pilha); com a pilha de pé, é a aplicação que não
  responde, e ele reprova; com o nginx da pilha parado (existe, e um `start` o
  religa sem conferência), reprova dizendo isso. Um site marcado como tirado que
  responde também reprova -- a marca é a intenção, e não a prova --, e o tirado
  com o vhost do host ainda ligado (502) também: o `tirar` não chegou ao fim. E se responde, as rotas negadas dão
  403, em mais de uma grafia — menos a de cupons e a de migrar senhas, que ele
  não sonda: se a regra faltasse, a sonda rodaria a rotina —, `noindex` e HSTS
  em `/` e `/novo/`, e a senha HTTP, se o `.env` diz `sim`. O cron e o endereço
  vêm primeiro: são as camadas de fora, e nada que acenda depois as esconde;
- a configuração **como a aplicação a monta** (os `module.config.php` e todo
  `config/autoload/*.php`): e-mail no Mailpit, integrações vazias, erro fora da
  página, cookie da sessão `Secure`, nenhum `*.local.php` além do `local.php`,
  nenhum `development.config.php`, e a aplicação entrando no banco com o
  usuário dela;
- os hosts de saída: as quatro perguntas do `getent`, e toda resposta tem de
  ser de loopback — resposta vazia reprova, porque não mediu;
- o app: o contêiner que roda nasceu da configuração em disco;
- a base: só os seis primeiros números da régua — chaves, ids do Asaas, links
  da produção e hashes previsíveis —, porque o uso normal mexe nos outros: o
  login grava `token_web`, e um cliente cadastrado aqui pode ter nome e e-mail
  de verdade. E o selo, que reprova a base de outro código.

O `.env` guarda também o domínio (`DOMINIO_TESTE`), e sem `DOMINIO` na linha
de comando é ele que vale, em todo subcomando. Uma rodada com outro `DOMINIO`
reprova antes de mexer, em vez de desligar o vhost errado -- o `publicar`
também; o `tirar`, que é freio, tira o do `.env` e diz que ignorou o dado. E o
freio só desliga o vhost que diz ter sido gerado por este script: o de outro
site, com o mesmo nome de arquivo, fica.

### A tabela dos aparelhos do push (10/10/2026, PUSH-FCM-APARELHOS; revista em 11/10/2026)

**O `deploy-teste.sh` não roda migração** (não há rodador: o `mysql.sql` só carrega num
volume novo, e o `atualizar` recusa a mudança de esquema até o `ACEITAR_ESQUEMA=sim`,
acima). A base do teste veio da cópia exportada em 02/10/2026, e a que subia em
11/10/2026 foi exportada antes também: **nenhuma das duas tem** `clientes_aparelhos`. A
cópia local a recebeu na manhã de 11/10/2026 (a resposta 72 do dono, nos dois bancos dela;
D461), e a próxima cópia exportada daqui a levará — vazia, e só depois que o endurecimento
a esvaziar e a régua a contar, que é o passo antes da próxima exportação (D327). Até a base
do teste vir de uma cópia dessas, a tabela entra **pela mão do dono, no BVIP, depois do
`atualizar`** — a migração já está na árvore depois do merge, e é idempotente (`CREATE
TABLE IF NOT EXISTS`; na segunda vez, não faz nada):

```bash
ACEITAR_ESQUEMA=sim bash scripts/deploy-teste.sh atualizar
DB_NAME="$(grep -E '^DB_NAME=' .env | tail -n 1 | cut -d= -f2- | sed 's/[[:space:]]#.*$//' | tr -d "\"'\r[:space:]")"
echo "banco: $DB_NAME"          # o do .env do teste (franquiabv_teste no modelo), e nunca outro
docker compose exec -T -e MYSQL_PWD=root mysql \
    mysql -uroot --default-character-set=utf8mb4 "$DB_NAME" < scripts/migracao-clientes-aparelhos.sql
```

**Conferido em 11/10/2026, pela leitura** (nada rodou no BVIP): o `DB_NAME` sai do `.env`
como o `deploy-teste.sh` o lê (a última linha, sem aspas e sem `\r`; a linha nova também
tira o comentário no fim da linha, que a anterior deixava entrar no nome); o `mysql` do
contêiner, o usuário `root` e a senha pelo `MYSQL_PWD` são os mesmos do `sql()` do
`deploy-teste.sh`; o `docker compose` lê o `COMPOSE_FILE` e o projeto do `.env` do teste,
desde que rodado **no diretório do clone do teste** (no da homologação, a mesma linha iria
ao banco dela); e a migração é a mesma que rodou na cópia local nesse dia, sem erro.

**E DE NOVO DEPOIS DE TODO `base`** com uma cópia exportada antes de 11/10/2026. O `base`
recria o banco do teste vazio (`recriar_banco`) e carrega só o dump da cópia, que não tem a
tabela: ela some. Os `atualizar` seguintes não lembram de nada — a recusa compara o commit
aplicado com o novo, e a migração já está nos dois —, e a rota volta a responder 503,
calada. Então todo `base` assim termina com as três últimas linhas do bloco acima (o
`DB_NAME`, o `echo` e a migração); e os aparelhos que o teste em aparelho tiver registrado
somem com ele.

A última instrução da migração lista os três índices da tabela (`PRIMARY`,
`idx_clientes_aparelhos_cliente` e `uq_clientes_aparelhos_identificador`, este com
`nao_unico` 0): é a conferência — a mesma saída que ela deu na cópia local em 11/10/2026.
**A ordem não importa para o ERP:** com o `push.provedor` do teste em `onesignal`, o envio
não toca a tabela, e a rota do aparelho (`/api/v4/clientes/:id/aparelhos`) responde 503 em
JSON enquanto ela não existir — medido em dois bancos descartáveis sem a tabela, um MySQL e
um PostgreSQL, em 10/10/2026. O `conferir` não olha a tabela, e o selo não muda com ela.

**O `push.provedor` do teste é `onesignal`** — o padrão: o `local.php` do teste foi
gerado do `docker/config/local.php.docker`, que o traz assim e com a conta de serviço
vazia (ou, gerado antes de 10/10, sem a seção `push`, que dá no mesmo). E o contêiner do
teste não alcança nem o OneSignal nem o Google (o loopback, acima): nenhum push sai daqui,
em modo nenhum — até o passo da seção seguinte.

**E a próxima cópia exportada precisa tratar a tabela** (D461, D327): os
identificadores ligam um aparelho a um cliente, e a linha guarda o telefone da sessão.
O lugar é o da irmã de token, `clientes_sessoes`: o `DELETE` no
`endurecer-base-teste.sql` e a contagem zero no `conferir-base-teste.sql`, que faz o
`conferir` falhar fechado. O gatilho, desde 11/10/2026, é **a próxima cópia exportada da
cópia local** (ela já tem a tabela, e o que um ensaio local gravar nela iria junto), ou a
primeira tirada da produção depois do passo 0. As duas mudanças mudam o selo, e por isso
ficaram para depois da cópia que subia em 11/10 (decisão do dono na resposta 72: "o selo
não muda agora"). Os cinco arquivos do selo não mudaram com esta tabela.

### O push pelo Firebase no teste (11/10/2026, a resposta 74 do dono)

**Para provar o push num iPhone e num Android de verdade antes da produção**, o teste
pode falar com os dois endereços do Google (`oauth2.googleapis.com`, o token da conta de
serviço, e `fcm.googleapis.com`, o envio), com **um projeto Firebase só de teste**, o
`push.provedor` em `ambos` e o **OneSignal ainda cortado**. O repositório está pronto; o
passo é do dono, e nada disto roda sem ele.

**O que o repositório tem:**

- o **`docker-compose.teste-firebase.yml`**, que não entra sozinho: ele troca a lista do
  loopback do app (`extra_hosts: !override`) pela de `HOSTS_MORTOS` — os dez nomes de
  sempre, sem os dois do Google — e monta, só para leitura, a pasta da conta de serviço do
  host (`/etc/franquiabv-teste`) em `/etc/franquiabv` dentro do app, fora do repositório.
  Sem ele, o teste corta o Google como a homologação (o compose **junta** as listas dos
  arquivos, e a da homologação o corta; só o `!override` desfaz isso). O
  `scripts/conferir-provedores-no-loopback.php` segura a lista dele igual a `HOSTS_MORTOS`
  e o arquivo só com a lista e a pasta;
- o **`HOSTS_MORTOS` do `deploy-teste.sh` sem os dois do Google**, que foram para
  `HOSTS_DO_GOOGLE` (a carga não mudou): o `conferir` segue exigindo no loopback o
  OneSignal, o Asaas, o WhatsApp, o SMS, o Yungas, a Zoomtec e o SMTP de produção, e o
  Google **só enquanto o arquivo do Firebase não estiver no `COMPOSE_FILE`**. A primeira
  versão (11/10/2026) deixava de exigi-lo também sem o arquivo, e o teste de sempre ficava
  sem o corte do Google medido no servidor; a verificação do mesmo dia o devolveu, e o
  `scripts/ensaiar-hosts-mortos-do-deploy.sh` roda a função do `conferir` com o Google
  aberto, com e sem o arquivo;
- o envio pelo FCM e o cadastro dos aparelhos (D461), que no `ambos` mandam pelo FCM a
  quem tem aparelho registrado e pelo OneSignal — cortado aqui — aos demais.

**A conta de serviço é do dono, e nunca entra no repositório.** Nem por e-mail nem por
conversa: o arquivo vai do console do Firebase direto ao BVIP.

**O passo, no BVIP, no diretório do clone do teste:**

1. **O projeto Firebase só de teste**, no console do Firebase: os dois aplicativos da
   versão de teste (o pacote Android e o bundle iOS dela), o `google-services.json` e o
   `GoogleService-Info.plist` desse projeto entregues a quem mantém o aplicativo
   (`docs/push-guia-do-aplicativo.md`, passo 2), e a chave `.p8` da APNs carregada nele
   (o passo 3 do guia). Projetos diferentes não se enxergam: a versão de teste do
   aplicativo tem de apontar para este projeto, e o da produção, para o dela.
2. **A conta de serviço do projeto de teste** (Configurações do projeto > Contas de
   serviço > Gerar nova chave privada), gravada fora do webroot, legível pelo grupo do
   php-fpm do contêiner (o `www-data`, gid 33 na imagem):

   ```bash
   getent group www-data            # o gid do www-data do host; se nao for 33, use 33 no lugar de www-data abaixo
   sudo install -d -m 0750 -o root -g www-data /etc/franquiabv-teste
   sudo install -m 0640 -o root -g www-data <o-json-baixado> /etc/franquiabv-teste/firebase-conta-de-servico.json
   ```

3. **O compose sabe o `!override`?** Pede o docker compose **2.24.4 ou mais novo**:
   `docker compose version`. Mais velho, não siga — o arquivo não faria o que diz.
4. **O arquivo no `COMPOSE_FILE` do `.env` do teste**, no fim:

   ```bash
   COMPOSE_FILE=docker-compose.yml:docker-compose.homologacao.yml:docker-compose.teste.yml:docker-compose.teste-firebase.yml
   ```

   e a conferência antes de subir: `docker compose config app | grep -c googleapis` dá
   **0** (o Google fora do loopback), e `docker compose config app | grep -c onesignal` dá
   **4** (os dois nomes do OneSignal, nos dois endereços, ainda no loopback).
5. **Subir o app com a lista nova:** `docker compose up -d app` e `docker compose restart
   nginx` (o nginx guarda o endereço do app da subida).
6. **O `local.php` do teste** (`config/autoload/local.php`), na seção `push`:

   ```php
   'push' => [
       'provedor' => 'ambos',
       'fcm' => [
           'conta_de_servico' => '/etc/franquiabv/firebase-conta-de-servico.json',   // o caminho DENTRO do app
       ],
   ],
   ```

   e `docker compose exec -T app php scripts/verificar-local-php.php` dizendo que o arquivo
   da conta serve (e o aviso do `ambos`, que lembra a tabela).
7. **A tabela dos aparelhos** na base do teste (a seção anterior).
8. **As conferências:** `bash scripts/deploy-teste.sh conferir` verde (os dez nomes no
   loopback, "fora o Google, que o docker-compose.teste-firebase.yml do COMPOSE_FILE
   abre", e a configuração segura); `docker compose exec -T app getent hosts
   fcm.googleapis.com` com um endereço que não é de loopback; e `docker compose exec -T app
   getent hosts onesignal.com` só com `127.0.0.1` e `::1`.
9. **Um push de verdade, à mão** (a verificação de 11/10/2026). **Nenhum cron roda aqui**
   (de propósito; a camada 4, acima), e o FCM só é chamado pela rotina da fila,
   `marketingPush`, que lê a fila `geral` da `push_app`, manda cada linha e **apaga cada
   linha que processa**. A outra que chama o FCM, a das campanhas (`marketingEnvios`),
   **não se roda aqui**: ela manda a todos os clientes da campanha. A ordem importa:

   1. **Primeiro, o aparelho de teste entra no login V4 e se registra** (a rota do
      aparelho, acima). **Pela mão do dono, no banco do teste** (a resposta 149, 12/10/2026;
      o D467): o código de entrada não chega a ninguém aqui -- o WhatsApp está no loopback, e
      o endurecimento apaga as chaves das unidades --, então ele fica na fila e é lido nela.
      Nenhum código muda. No diretório do clone do teste, com o `DB_NAME` do bloco da seção
      anterior:

      1. **O WhatsApp central "configurado" no `local.php` do teste** -- desde 12/10/2026 (a
         resposta 147 do dono ao D59) o código de entrada sai pelo WhatsApp central da
         franqueadora, e não mais pelo da unidade do cliente; o login só cria a sessão
         quando o `whatsapp.central` do `local.php` tem a instância e a chave
         (`ISBase\Util\WhatsAppCentral::daConfiguracao`). Valores inventados servem, e a
         fila não os guarda (a linha do código leva o provedor `central`, sem chave).
         Nada sai, porque nenhum cron roda aqui e o provedor está no loopback. No
         `config/autoload/local.php` do teste, e espere uns segundos (o opcache relê o
         arquivo a cada 2 s):

         ```php
         'whatsapp' => ['central' => ['provedor' => 'evolution', 'instancia' => 'teste', 'api_key' => 'x']],
         ```

         (Até 12/10/2026 este passo ligava o WhatsApp de uma unidade com
         `UPDATE unidades SET whatsapp_ativo = 1, whatsapp_api_key = 'x'`; isso não basta
         mais, nem é preciso. A 149 dizia "nenhum código muda": mudou o canal, pela 147, e
         o passo é este.)

      2. **Um cliente de teste com o telefone de quem testa**, ativo, numa unidade qualquer
         -- um da cópia anonimizada serve, e o id dele é o `:id` da rota do aparelho. O
         número vai como a coluna o guarda, com o `+`, o DDI e só os dígitos:

         ```bash
         docker compose exec -T -e MYSQL_PWD=root mysql \
             mysql -uroot --default-character-set=utf8mb4 "$DB_NAME" -e "
             UPDATE clientes SET status = 1, telefone = '+55 (47) 9XXXX-XXXX', telefone_sem_mascara = '+5547XXXXXXXXX'
              WHERE id = <o cliente> AND unidade = <a unidade>;"
         ```

      3. **Quem testa pede o código no aplicativo** (`POST /api/v4/sessoes`, a tela de
         entrada da versão de teste). A resposta é 202, e o código vai para a fila:

         ```bash
         docker compose exec -T -e MYSQL_PWD=root mysql \
             mysql -uroot --default-character-set=utf8mb4 "$DB_NAME" -e "
             SELECT id, conteudo FROM push_app WHERE titulo = 'Acesso ao aplicativo' ORDER BY id DESC LIMIT 1;"
         ```

         O `conteudo` diz o código ("Seu codigo de acesso e ..."), que vale 10 minutos. O dono
         o passa a quem testa, que o digita no aplicativo; o aplicativo entra, e registra o
         aparelho. São no máximo 5 pedidos por telefone por dia.

      4. **Desfeito depois do teste:** o `whatsapp.central` do `local.php` do teste volta
         vazio (a instância e a chave `''`), como o modelo. A linha do código na fila pode
         ficar: a rotina do passo 3 a tenta, no loopback, onde não chega a ninguém, e -- desde
         12/10/2026 (o D302) -- a deixa na fila marcada como `falhou:geral`, sem tentar de
         novo. **A rotina não se chama antes de o código ser lido.**
   2. **A linha do push**, para o cliente com que o aparelho se registrou (o `:id` da rota),
      no diretório do clone do teste e com o `DB_NAME` do bloco da seção anterior:

      ```bash
      docker compose exec -T -e MYSQL_PWD=root mysql \
          mysql -uroot --default-character-set=utf8mb4 "$DB_NAME" -e "
          INSERT INTO push_app (tipo, destinatario, titulo, conteudo, link, data, cronjob)
          VALUES ('app', '<o id do cliente>', 'Teste do push', 'O push pelo Firebase chegou', NULL, NOW(), 'geral');
          SELECT LAST_INSERT_ID() AS linha;"
      ```

   3. **Um token das rotinas só para a medida.** O `cron.token` do `local.php` do teste é
      vazio, e sem ele toda rotina responde 401. Sorteie um na hora (`openssl rand -hex 32`),
      ponha-o em `'cron' => ['token' => ...]` no `config/autoload/local.php` do teste,
      espere uns segundos (o opcache relê o arquivo a cada 2 s) e chame a rotina de dentro
      do BVIP, pela porta do app (a `APP_PORT` do `.env`), que não passa pelo nginx do host:

      ```bash
      curl -sS -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8092/api/marketing/marketingPush
      ```

      Nunca a `marketingPushOutros` (as filas de aniversário e de "sentimos sua falta",
      com as linhas que vieram na cópia) nem a `marketingEnvios`. **E o token volta a
      vazio logo depois**: com ele, qualquer rotina responde a quem estiver dentro do BVIP.
   4. **A conferência:** a resposta diz `CronJob (geral) processado com sucesso.`; a linha
      saiu da fila (`SELECT COUNT(*) FROM push_app WHERE id = <a linha>` dá 0; a rotina lê
      até 100 linhas da fila `geral` por vez, sem ordem: na cópia local de 11/10/2026 a fila
      `geral` tinha 1 linha, e as outras 5.140 da tabela eram das duas filas que ela não lê);
      e o aparelho recebe. Se não receber, o motivo do FCM está em `data/logs/log_scripts.txt`
      dentro do app (uma linha por motivo; o envio que dá certo não escreve nada).

**Quem recebe:** só o aparelho que a versão de teste do aplicativo registrar pela rota do
teste (`POST /api/v4/clientes/:id/aparelhos`, com a sessão V4). Os clientes da base do
teste são a cópia anonimizada, sem aparelho nenhum: ninguém de verdade recebe nada.
**A entrada do aparelho é pela mão do dono:** a sessão V4 nasce de um código mandado pelo
WhatsApp central da franqueadora (a resposta 147, 12/10/2026), e no teste o WhatsApp está
cortado e o `local.php` não tem a credencial central. O dono decidiu (a resposta 149,
12/10/2026; o D467) que o código fica na fila e é lido nela, no banco do teste -- o passo 1
acima, com o central de mentira no `local.php` do teste.

**Para desfazer:** tire o arquivo do `COMPOSE_FILE`, `docker compose up -d app` e
`docker compose restart nginx`, e volte o `push.provedor` a `onesignal`. Sem o arquivo, o
`conferir` volta a exigir o Google no loopback, e só passa depois do `up -d app`. **Antes de um
`voltar` a um commit anterior a 11/10/2026, tire a linha do `COMPOSE_FILE`:** o arquivo não
existe lá, e o compose recusaria subir a pilha (o site fica fora do ar, fechado, até a
linha sair). E o `conferir-provedores-no-loopback.php --conteiner`, que a homologação roda,
não é para o app do teste com o arquivo: lá os dois do Google resolvem de verdade, de
propósito, e quem mede é o `deploy-teste.sh conferir`.

## O desempenho do teste (14/10/2026, o D493)

O MySQL e o PHP-FPM da pilha não ficam no padrão de fábrica: o `docker-compose.teste.yml` monta
`docker/mysql/teste.cnf` (o buffer pool de 4 GB, que guarda a base de ~2,3 GB inteira) e `docker/php/fpm-teste.conf`
(16 processos do PHP-FPM, e não os 5 da imagem). Com os 128 MB de fábrica, a comanda e a agenda da tela nova liam
quase tudo do disco (55,7 GB em duas horas, medido no BVIP). Depois de um `atualizar` ou de um `base`, que recriam o
MySQL, os primeiros pedidos ainda vêm do disco, até o pool encher. O `.cnf` só vale em modo 644: o MySQL ignora
`.cnf` com escrita para todos.

## Ferramentas de apoio

Mailpit (os e-mails que o sistema mandaria), Adminer e o MySQL do teste só por
túnel SSH:

```bash
ssh -L 8026:127.0.0.1:8026 -L 8093:127.0.0.1:8093 -L 3309:127.0.0.1:3309 usuario@bvip
```

**As portas do app e do Adminer são 8092 e 8093 desde 07/10/2026**, e eram 8090
e 8091 -- as mesmas que a `docs/ambiente-homologacao.md` manda a homologação
usar quando a 8080 está ocupada pelo nginx do BVIP. O `.env` gerado pelo
`preparar` antes dessa data continua com as antigas, e vale o que estiver nele:
o deploy lê a porta do `.env`, e não do exemplo. Confira com
`grep -E '^(APP|ADMINER)_PORT=' .env` e troque `8093` e `8092` nos comandos
desta página pelas de lá. As outras portas do teste não mudaram: Mailpit 8026,
MySQL 3309, PostgreSQL 5434 e MinIO 9002 e 9003, todas no loopback.

O `root` do MySQL do teste tem a senha do `docker-compose.yml` (`root`), como o
da homologação: a porta só atende no loopback do BVIP.

## O que fica de fora, e por quê

- **As rotinas agendadas não rodam.** A tela de Atendimentos lê o que elas
  gravam em `data/cache/relatorios`; sem rotina, mostra o que estiver lá (a da
  Evasão também lia, até sair do ERP em 04/11/2026, com a rotina dela). Uma rotina pode ser rodada à mão de dentro do servidor, por
  `http://127.0.0.1:8092/api/cronjobs/<rotina>` (as do marketing, por
  `/api/marketing/<rotina>`; a `APP_PORT` do `.env`; 8090
  num `.env` de antes de 07/10/2026), que não passa pelo nginx do host, com o
  `cron.token` do `local.php` no `Authorization: Bearer` — o do teste é vazio, e
  sem ele toda rotina responde 401 (o passo 9 de "O push pelo Firebase no teste"
  diz como pôr um só para a medida e tirá-lo) — **nunca** as duas do Asaas
  (`relatoriosRoyaltiesFaturasGerar` e `relatoriosRoyaltiesFaturasAtualizar`).
- **O texto livre da base é genérico** ("Motivo de exemplo"): quem testa não
  vê o que a equipe escreveu.
- **A base real para em 16/08/2026** (a cópia de 02/10/2026, D327): os
  relatórios de setembro em diante saem vazios, e 38 logins têm um caixa aberto
  de outro dia -- a venda é recusada até fechá-lo. Vêm junto as fixtures da
  suíte (quatro unidades de teste nos seletores) e o contador das vendas na casa
  de 990 milhões. E a cópia leva um CPF e 243 telefones em links que o
  anonimizador não alcança (D326): por isso a senha HTTP fica ligada -- e ela
  precisa do `SENHA_HTTP=sim` no primeiro `publicar` (D451).
- **A cópia só tem 4 UFs** (SP, SC, RJ e a ZY de fixture; medido no
  `base-teste.sql.gz` de 02/10 em 08/10/2026). O ERP antigo monta o campo
  obrigatório de estado da tabela `estados`, e o primeiro cliente de outra UF não
  se cadastra por ele até alguém recarregar a página: a busca de CEP grava a UF,
  mas a página aberta não a ganha; e a edição de um cliente de UF ainda não
  consultada vai com o estado vazio e é recusada (D430). A tela nova não sofre:
  ela usa a lista das 27.
- **O grupo Líder ainda não tem a visão de gerente** (D124): a migração
  `scripts/migracao-grupo-lider-gerente.sql` só roda na janela do corte, e a cópia
  segue com `gerente = 0` no grupo 8 (D327). Quem entra como Líder cai no `else`
  dos relatórios, e os PDFs saem sem linha nenhuma, sem erro -- não é defeito do
  teste.
- **A resposta do visagismo** (`visagismo.resposta`, 2.120 na cópia) segue como
  veio: o visagismo é débito futuro, por decisão do dono.
- **O limite de pedidos por IP do nginx do contêiner** vê todo mundo como o
  mesmo endereço, porque tudo chega pelo nginx do host.
- **O aplicativo** não aponta para cá.
- **Numa máquina Windows** (ensaio local), o `docker compose` separa os arquivos
  do `COMPOSE_FILE` com `;`: rode com `COMPOSE_PATH_SEPARATOR=:`.

## Desmontar

```bash
docker compose down            # para a pilha e mantém o banco
docker compose down -v         # APAGA o banco de teste (só o do projeto franquiabv-teste)
sudo rm /etc/nginx/sites-enabled/sistema.franquiabv.xyz.conf && sudo systemctl reload nginx
```
