# Procedimento de corte — MySQL para PostgreSQL

Escrito em 27/08/2026, a partir do que foi ensaiado localmente nas frentes F1.3
a F1.9.

**Este procedimento nunca foi executado em produção.** O que ele traz é a
sequência que funcionou no laboratório, com os portões que foram construídos
junto e os pontos onde o laboratório sabidamente não representa o parque real.
Onde eu não sei, está escrito que não sei.

> **14/10/2026 — o corte foi partido em duas etapas, por decisão do dono:** *"escreva a variante do procedimento de
> corte. guarde todo o trabalho feito do lado do postgres, mas vamos colocar a migração do mysql para o postgres em um
> segundo momento."* A **etapa 1** — o código novo sobre o MySQL de produção, com o passo 0 — está em
> `docs/procedimento-de-corte-etapa-1.md`. **Este documento é a etapa 2**, a migração do MySQL para o PostgreSQL, e fica
> guardado inteiro: nada daqui foi apagado nem mudado. Quando ele rodar, o que a etapa 1 já tiver feito é **conferido, e
> não refeito**:
>
> - **o passo 0:** o laço roda de novo e vira a conferência — as migrações são idempotentes, e cada uma diz que já existe;
> - **o código novo no ar:** o 6 de "O que hoje bloqueia" e o deploy que os passos 3 e 9 pressupõem; o `local.php` que o
>   passo 7 guarda é o do MySQL que a etapa 1 criou;
> - **o crontab na forma nova, que a etapa 1 instala** (a resposta 194 do dono), e a linha de `crontab-a-aplicar.md`: o
>   primeiro bloco do passo 9 não muda linha nenhuma, e o token não se rotaciona de novo. Se a etapa 1 tiver ido pelo
>   recuo do passo 12 dela (a resposta 193), o crontab guardado é o de antes sem as quatro linhas das rotinas que saíram, e
>   o passo 9 daqui o troca;
> - **a semente da vigência no MySQL:** a do 5b acrescenta zero, porque a carga já traz a tabela cheia;
> - **o php-fpm** do item 4 de "Preparação";
> - **do passo 3, o Líder** (a resposta 192, a opção (a)): roda de novo sem mudar nada; o resto do passo 3 roda aqui pela
>   primeira vez.
>
> **As quinze escolhas da etapa 1 (CORTE-EM-DUAS-ETAPAS, 189 a 203) foram respondidas pelo dono em 14/10/2026**, e estão
> aplicadas no documento novo, com a tabela de onde cada uma entrou. Duas delas, e duas respostas de 12/10/2026, mudam
> texto daqui **numa rodada própria, com os portões juntos**: **o índice da grade da agenda** (as respostas 126 e 196, o
> **D189**) entra no passo 0 quando a migração existir; e **o aviso das rotinas vai ao log do sistema, sem e-mail** (a
> resposta 128, que muda a 31; e a 194) — **feito em 07/11/2026** (a fatia 113, o **D331**): os dois blocos do passo 9
> instalam o crontab sem endereço, com o `|| logger -t rotinas` em cada linha, o domínio raiz e o aviso da apuração dos
> royalties (o **D364**), e o 8 de "O que hoje bloqueia" passou a testar o log do sistema. E duas limpezas de dentro do
> MySQL (a resposta 127, o **D26**) entram já na etapa 1, como rotinas do crontab (a resposta 195) — as rotinas existem
> desde a mesma fatia, e as linhas estão em `crontab-a-aplicar.md`.
>
> O que a etapa 1 muda nas frases daqui está em "O que a etapa 2 herda", no documento novo.

---

## O que já está automatizado

Quase tudo. O que sobra para a pessoa é decidir e conferir.

| Passo | Como |
|---|---|
| **Conferir que o passo 0 está completo** | `php scripts/conferir-passo-zero-do-corte.php` — toda diferença entre o `mysql.sql` de hoje e o do dump de produção tem a sua migração no passo 0 (D323) |
| Limpar dados que o PostgreSQL recusa | `scripts/pre-migracao-limpeza.sql` |
| Gerar o schema | `php scripts/gerar-ddl-postgres.php > postgres/schema.sql` |
| Gerar o roteiro de carga | `php scripts/gerar-migracao-dados.php > scripts/migrar-dados.sh` |
| Criar os papéis | `SENHA_APP=… sh scripts/criar-papeis.sh` |
| Carregar os dados | `sh scripts/migrar-dados.sh` |
| **Conferir a carga** | `php scripts/conferir-migracao.php` — seis provas |
| **Conferir os papéis** | `SENHA_APP=… sh scripts/conferir-papeis.sh` — seis provas |
| Comportamento financeiro | `phpunit --testsuite caracterizacao` — **antes da janela, nunca contra a produção** (passo 8, D378) |

---

## O fuso: leia isto antes de qualquer outra coisa

**É o defeito mais perigoso que apareceu em toda a fase**, e só foi encontrado
na revisão de 27/08 — depois de a carga inteira já ter passado nas quatro provas
que existiam.

O contêiner PostgreSQL nasce em **UTC**. O dump do MySQL traz **hora local, sem
fuso**: `2017-05-13 18:34:51`. As 143 colunas de data e hora são `timestamp with
time zone` (eram 133 em 27/08; contadas em `postgres/schema.sql` em 03/10/2026). Numa sessão UTC, aquele texto é lido como 18:34 **UTC** — que é 15:34 em
São Paulo.

**Toda data do sistema fica três horas atrasada.**

E é invisível: enquanto a aplicação também estiver em UTC, ida e volta ficam
consistentes e os dígitos exibidos batem. Os 67 testes de caracterização
passaram. As quatro provas da carga passaram. O erro aparece no dia em que
alguém ajusta o fuso para o correto — que é exatamente o que se faz ao ir para
produção.

O conserto está no gerador: o schema emite

```sql
ALTER DATABASE <banco> SET timezone = 'America/Sao_Paulo';
```

no mesmo lugar onde já fixava o `search_path`, e pelo mesmo motivo — toda sessão
precisa, e nenhuma deve ter de lembrar.

**Antes do corte, confira que o fuso do banco está ajustado ANTES de carregar os
dados.** Carregar primeiro e ajustar depois não conserta nada: o dado já entrou
errado.

```bash
psql -tAc "SHOW timezone;"          # tem que dizer America/Sao_Paulo
```

A quinta prova de `conferir-migracao.php` existe por causa disto, e é a única
que enxerga o problema — compara mínimo e máximo de cada coluna de data **como
texto**, que é como a data chega à tela.

---

## Antes da janela

### O que hoje bloqueia

Produção está congelada por decisão de 23/08. Estes passos precisam dela:

| | O que | Por quê bloqueia |
|---|---|---|
| 0 | **Aplicar `migracao-pontuacao-validade-unidade.sql`** em produção | acrescenta `unidades.pontuacao_validade_dias`, de que a aplicação já depende. **Antes do passo 1**, ou o dump refeito não a terá — ver **D54** |
| 0 | **Aplicar `migracao-venda-autorizacao-fiado.sql`** em produção | acrescenta `vendas.autorizado_por` e `vendas.autorizado_em`, de que a tela de vendas já depende. Mesma armadilha e mesma ordem — **antes do passo 1** — ver **D86** |
| 0 | **Aplicar `migracao-usuarios-horarios.sql`** em produção | cria `usuarios_horarios`, a vigência do horário semanal (F4.1 do Profissional Parceiro, `f3-relatorios.md`, seção 5). Desde 02/10/2026 **salvar usuário ou horário grava nela**: sem a tabela no `mysql.sql` refeito, ela some do `schema.sql` e o ERP dá 500 no primeiro dia. Para o código antigo de produção a tabela vazia é inofensiva. **Antes do passo 1.** Ver **D323** |
| 0 | **Aplicar `migracao-clientes-sessoes.sql`** em produção | cria `clientes_sessoes`, a sessão do cliente no aplicativo por código no WhatsApp (`/api/v4`, **D59**). Sem ela, todo pedido de código dá 500. Tabela vazia, que o código antigo não lê. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-pontuacao-resgate-autorizado.sql`** em produção | acrescenta `pontuacoes_historicos.autorizado_por`, o gerente que autorizou o resgate sem o código do cliente (**D88**). A entidade mapeia a coluna: sem ela, **toda** leitura e gravação do histórico de pontos falha, o resgate com código junto. **Copia a tabela** (ver abaixo): fora do movimento. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-unidade-hash-anterior.sql`** em produção | acrescenta `unidades.hash_anterior`, `hash_anterior_expira_em` e `hash_anterior_usado_em`, a rotação do hash da unidade (**D77**). A entidade `Unidade` mapeia as três: sem elas, **toda** leitura de unidade falha — não são três rotas, é quase todo o ERP. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-agendas-indice-id-pai.sql`** em produção | cria o índice `IDX_AGENDAS_id_pai` em `agendas`, com o qual todo Desmarcar acha a família do horário sem ler a agenda inteira: na cópia, 2,8 s por busca no MySQL e 0,2 ms com ele (**D220**). **Decidido em 08/10/2026** (pergunta 50 da página do lote, a opção (a)): na janela, por esta migração, e o mesmo `KEY` no `mysql.sql`; o PostgreSQL o herda pelo gerador, **sem linha à mão** (ela sairia com o mesmo nome do herdado e pararia o passo 4 em "already exists"). Nenhum código depende dele: sem ele em produção, o `KEY` some no passo 1 e o PostgreSQL nasce sem o índice, com o Desmarcar lento como hoje. **Não copia a tabela** (`INPLACE`, `LOCK=NONE`: as escritas na agenda seguem); de 11 a 20 s nos 4 milhões de linhas (a medida do **D189**). **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-clientes-aparelhos.sql`** em produção | cria `clientes_aparelhos`, os aparelhos dos clientes para o push pelo Firebase: o token do FCM ou o FID, a plataforma, a versão do aplicativo e o telefone da sessão que o registrou (o aparelho só recebe enquanto ele for o do cliente), e um aparelho é de **um** cliente por vez (o identificador é único). A rota `POST /api/v4/clientes/:id/aparelhos` a escreve e o envio pelo FCM a lê (**D461**); com `push.provedor` em `onesignal`, o padrão e o da produção, o envio não a toca. Tabela nova e vazia, que o código antigo não lê, com a chave estrangeira para `clientes` (só uma trava curta de metadados nela; **não copia tabela**). Sem ela em produção, a tabela some no passo 1 e a rota do aparelho responde 503 (`indisponivel`, em JSON) desde o primeiro dia. **Na cópia local desde 11/10/2026**, nos dois bancos, pela autorização do dono (a resposta 72 do lote), e por isso no laço do ensaio geral, como as outras. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-cupons-clientes-desconto-produto.sql`** em produção | acrescenta `cupons_clientes.desconto` e `cupons_clientes.produto_oferecido`, o desconto e o produto do dia em que o cupom foi dado (**D271**, a resposta 94 do dono): a emissão os grava, e o caixa, o aplicativo e a comanda nova os leem; nulos nos cupons de antes, que seguem a campanha. A entidade **não** as mapeia, porque a base do teste do BVIP não as tem até a próxima cópia exportada (**D473**): o código pergunta se elas existem (`ISBase\Doctrine\ColunaNoBanco`), e sem elas em produção, que é o que o passo 1 faria se esta linha fosse esquecida, o cupom volta a seguir a campanha, **sem erro**. **Copia a tabela** (a chave estrangeira para `produtos`; 100.375 linhas na cópia em 12/10/2026, 1 s no servidor dela): fora do movimento. **Na cópia local desde 12/10/2026**, nos dois bancos (a resposta 83 do dono, a opção (a): o esquema que só acrescenta entra nas duas cópias locais), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-vendas-produtos-pacote-recusado.sql`** em produção | acrescenta `vendas_produtos.pacote_recusado`, o "Usar pacote contratado: Não" do PDV antigo gravado no item (**D453**, a resposta 80 do dono, a opção (b)): o item lançado com "Não" não herda a sessão do item tirado da venda. O padrão 0 é o de todo item de antes, do "Sim", da tela nova e da comanda pela agenda, que herdam. A entidade **não** a mapeia, porque a base do teste do BVIP não a tem até a próxima cópia exportada (**D473**): o código pergunta se ela existe (`ISBase\Doctrine\ColunaNoBanco`), e sem ela em produção, que é o que o passo 1 faria se esta linha fosse esquecida, o "Não" volta a valer só para o lançamento, **sem erro**. **Não copia a tabela**: `ALGORITHM=INSTANT`, que o 8.0.46 de produção tem para a coluna no fim da tabela (3.264.661 linhas na cópia em 12/10/2026; 2 s no servidor dela, com a conferência que conta as linhas). Se o servidor recusar o `INSTANT`, o `ALTER` para com erro, e sem o `ALGORITHM` ele copia a tabela -- fora do movimento. **Na cópia local desde 12/10/2026**, nos dois bancos (a resposta 83 do dono, a opção (a)), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-saidas-motivos-e-autores.sql`** em produção | acrescenta, em Contas a Pagar, o motivo e o autor do que desfaz ou dispensa dinheiro (**D205**, **D207** e **D199**; as respostas 139, 140 e 18 do dono): `saidas.excluida_por` e `excluida_em_nome_de`; `saidas_parcelas.quitacao_motivo`, `excluida_por` e `excluida_em_nome_de`; `saidas_parcelas_pagamentos.estorno_motivo`, `estornado_por` e `estornado_em_nome_de`. O estorno e o "Quitar" sem pagamento passam a exigir o motivo e a guardá-lo, e o estorno não desfaz a quitação com motivo; as exclusões e o estorno guardam quem os fez (o `CrudController::autorDaEscrita`). Nulas em toda linha de antes. As entidades **não** as mapeiam, porque a base do teste do BVIP não as tem até a próxima cópia exportada (**D473**): o código pergunta se elas existem (`ISBase\Doctrine\ColunaNoBanco`), e sem elas em produção, que é o que o passo 1 faria se esta linha fosse esquecida, Contas a Pagar volta a não guardar o motivo nem o autor, e o estorno volta a reabrir a quitação com motivo, **sem erro**. **Não copia as tabelas**: `ALGORITHM=INSTANT`, colunas no fim (13.238 despesas, 15.076 parcelas e 13.902 pagamentos na cópia em 13/10/2026; 2 s no servidor dela, com a conferência). Se o servidor recusar o `INSTANT`, o `ALTER` para com erro. **Na cópia local desde 13/10/2026**, nos dois bancos (a resposta 83 do dono, a opção (a)), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-royalties-aliquotas.sql`** em produção | cria `royalties_aliquotas`, a vigência da taxa de royalty (**D368**, a resposta 134 do dono, a opção (b)): mudar a taxa de uma unidade vale do ciclo seguinte -- o da fatura, de 16 a 15 --, e cada mudança guarda quando e quem (o `CrudController::autorDaEscrita`); a primeira guarda antes as taxas de antes, "desde sempre" (o `ciclo_inicial` nulo). O retrato das 05:25 apura cada dia com a taxa do ciclo dele, e a fatura do dia 16 soma o retrato. Tabela nova e vazia, sem semente e sem entidade: o código pergunta se ela existe (`ISBase\Doctrine\ColunaNoBanco`), e sem ela em produção, que é o que o passo 1 faria se esta linha fosse esquecida, a taxa nova volta a valer no dia em que muda, **sem erro** e sem guardar quem mudou. O código antigo de produção não a lê nem a escreve; a chave estrangeira para `unidades` só pede uma trava curta de metadados (**não copia tabela**). **Na cópia local desde 13/10/2026**, nos dois bancos (a resposta 83 do dono, a opção (a)), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-clientes-creditos-devolucoes.sql`** em produção | cria `clientes_creditos_devolucoes`, o registro da devolução de crédito (**D179**, a resposta 89 do dono, a opção (b)): cada devolução grava o cliente, a unidade, o valor, quem autorizou (o gerente da senha, com chave estrangeira para `usuarios`), quem operou (`CrudController::autorDaEscrita`, sem chave) e a retirada do caixa aberto por onde o dinheiro saiu (chave para `caixas_retiradas`), e o extrato do crédito a mostra. Tabela nova e vazia, sem entidade: o código pergunta se ela existe (`ISBase\Doctrine\ColunaNoBanco`), e sem ela em produção, que é o que o passo 1 faria se esta linha fosse esquecida, a devolução sai do caixa como retirada (o motivo leva o cliente) e **não deixa a linha do registro**, sem erro. O código antigo de produção não a lê nem a escreve; as quatro chaves estrangeiras só pedem uma trava curta de metadados (**não copia tabela**). **E as duas colunas de quem desfez a devolução e quando** (`desfeita_por`, sem chave, e `desfeita_em`; o **D500**, a resposta 231 (a): cancelar a retirada da devolução desfaz a devolução e devolve o crédito), no `CREATE TABLE` e num `ALTER` idempotente para a tabela que já existe sem elas (`ALGORITHM=INSTANT`); sem elas, cancelar essa retirada é o cancelamento de antes. **Na cópia local desde 15/10/2026** (as colunas, desde 16/10/2026), nos dois bancos (a resposta 83 do dono, a opção (a)), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-sorteios-regra-do-cupom.sql`** em produção | acrescenta `sorteios.regra_cupom` e `sorteios.valor_cupom`, a regra com que cada sorteio dá cupom -- um por venda (o padrão, e a de todo sorteio de antes), um a cada R$ X do valor da venda, ou um por serviço lançado nela --, e `sorteios_vendas.numero`, o número do cupom dentro da venda (1, 2, 3...; o 1 em todo cupom de antes) (**D561**, o pedido do dono de 21/10/2026: "o cadastro escolhe a regra" e "trocar a regra do banco"). A tela nova escolhe a regra no cadastro; a finalização da venda dá os cupons que a regra manda, numerados, sob a trava do sorteio. As entidades **não** as mapeiam, porque a base do teste do BVIP não as tem até a próxima cópia exportada (**D473**): o código pergunta se elas existem (`ISBase\Doctrine\ColunaNoBanco`), e sem elas em produção, que é o que o passo 1 faria se esta linha fosse esquecida, todo sorteio volta a ser "um cupom por venda" e a tela nova recusa as outras duas regras com a frase de que o banco ainda não as tem, **sem erro**. **Não copia as tabelas**: `ALGORITHM=INSTANT`, colunas no fim (6 sorteios e 7.212 cupons na cópia em 21/10/2026). Se o servidor recusar o `INSTANT`, o `ALTER` para com erro. **O índice único (sorteio, venda, numero) não está nela**: ele espera a limpeza dos 4 cupons repetidos, que apaga linha, no passo 3 (a linha dos cupons repetidos, logo abaixo das que rodam no passo 3). **Na cópia local desde 21/10/2026**, nos dois bancos (a resposta 83 do dono, a opção (a)), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-arquivos-acessos.sql`** em produção | cria `arquivos_acessos`, o registro de quem abre e de quem baixa cada arquivo da franqueadora (**D147**, a resposta 253 do dono, a opção (c): "um registro por pessoa: quem abriu, qual arquivo e quando"): cada entrega que sai -- o "abrir" e o "baixar", pelas duas portas (o ERP e a `/adm/api/arquivos/:id/conteudo`, as duas pelo `EntregaDoArquivo`) -- grava o arquivo, quem entrou na sessão e a pessoa assumida quando difere (`CrudController::autorDaEscrita`, sem chave), a unidade da sessão, se foi abrir ou baixar, e quando; o 404 e a recusa não gravam, e a falha ao gravar não impede a entrega (vai ao log). O administrador que cadastra vê "quem abriu" de cada arquivo no Cadastro de arquivos da tela nova. Tabela nova e vazia, sem entidade: o código pergunta se ela existe (`ISBase\Doctrine\ColunaNoBanco`), e sem ela em produção, que é o que o passo 1 faria se esta linha fosse esquecida, o arquivo é entregue como antes e **não deixa a linha do registro**, sem erro. O código antigo de produção não a lê nem a escreve; as duas chaves estrangeiras (`arquivos` e `unidades`) só pedem uma trava curta de metadados (**não copia tabela**). O prazo de guarda, quem vê e o aviso aos funcionários são da LGPD, débito futuro pelo dono (**D583**). **Na cópia local desde 22/10/2026**, nos dois bancos (a resposta 83 do dono, a opção (a)), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-unidades-whatsapp-eventos.sql`** em produção | cria `unidades_whatsapp_eventos`, o rastro do WhatsApp da unidade (**D591**, a F3 do desenho `docs/f3-telas-restantes/tela-whatsapp.md`, pela resposta 136 (a) do dono): cada pedido de código QR e cada desconexão que passou pelas guardas e chegou ao provedor -- pelas duas portas, a tela antiga (`/mod-configuracoes/whatsapp/codigo` e `/desconectar`) e a `POST /adm/api/whatsapp/conexao` e `/desconexao` -- grava a unidade, o evento (`codigo_pedido` ou `desconexao`), o resultado (`sucesso` ou `falha`), o motivo tratado da falha (nunca o texto do provedor, a chave, a instância nem o QR), quem entrou na sessão e a pessoa assumida quando difere (`CrudController::autorDaEscrita`, sem chave), e quando; a consulta do estado, o 401, o 403, o 404 e todo 409 (o banco, a trava, o estado) não gravam, e a falha ao gravar não impede o pedido (vai ao log). A `GET /adm/api/whatsapp` devolve os últimos (`ultimos`), que a tela nova mostra (a fatia 34). Tabela nova e vazia, sem entidade: o código pergunta se ela existe (`ISBase\Doctrine\ColunaNoBanco`), e sem ela em produção, que é o que o passo 1 faria se esta linha fosse esquecida, o código sai e a desconexão acontece como antes e **não deixam rastro**, sem erro (`ultimos: null`). O código antigo de produção não a lê nem a escreve; a chave estrangeira para `unidades` só pede uma trava curta de metadados (**não copia tabela**). O prazo de guarda, quem vê e o aviso aos funcionários são da LGPD, débito futuro pelo dono (**D583**). **Na cópia local desde 23/10/2026**, nos dois bancos (a resposta 83 do dono, a opção (a)), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-colunas-gravadas.sql`** em produção | cria `colunas_gravadas`, o dia em que o PDV passou a gravar cada coluna que os relatórios esperam (a fatia 43 da fila das telas que faltam, REL-CONSULTA-E-EXPORTACAO; a C2 de `docs/f3-relatorios/spec-comum.md` e a 1.5 de `docs/f3-relatorios/spec-ordem.md`): uma linha por coluna -- `cancelamento`, `descontoManual`, `motivoDaRetirada`, `valorContado`, `cotaDoPacote` e `indicacaoDoFormulario` (`ISRelatorio\Regra\ColunasDoPdv`) --, o nome e o instante em que a migração DAQUELA coluna rodou neste ambiente, para o relatório dizer "gravado desde dd/mm/aaaa" e deixar nulo o número de um período inteiro anterior. **Nasce vazia, e esta migração não grava linha nenhuma**: cada migração da fatia do PDV grava a sua, depois de criar a coluna. Tabela nova, sem chave estrangeira e sem entidade: o código pergunta se ela existe (`ISBase\Doctrine\ColunaNoBanco`), e sem ela em produção, que é o que o passo 1 faria se esta linha fosse esquecida, o relatório diz "ainda não gravado" em toda coluna, sem erro. O código antigo de produção não a lê nem a escreve (**não copia tabela**). **Na cópia local desde 26/10/2026**, nos dois bancos (a resposta 83 do dono, a opção (a)), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-venda-cancelamento.sql`** em produção | acrescenta `vendas.cancelado_em` e `vendas.cancelado_por` (com a chave `FK_VENDAS_cancelado_por` para `usuarios`), quando a venda foi cancelada e qual gerente autorizou, e `vendas_produtos.desconto_lancado_por` (com a chave), quem lançou o desconto manual (a pergunta 2 do dono; a fatia 70 da fila das telas que faltam, PDV-CANCELAMENTO, e a P0 de `docs/f3-relatorios/spec-ordem.md`). Nulas em toda linha de antes. O cancelamento pelas duas telas grava o instante e o gerente cuja senha conferiu, e a rotina das comandas abandonadas só o instante (`VendaService::cancelar`, `CronJobService::comandasCancelar`); o escritor de `desconto_lancado_por` entra na fatia 71. **A migração NÃO grava a data em `colunas_gravadas`**: a linha `cancelamento` só se escreve DEPOIS do código que grava: na etapa 1, no passo 7 dela (`procedimento-de-corte-etapa-1.md`), logo depois da troca da árvore, e a carga a traz; no corte de uma vez só, no "7d", abaixo -- o código antigo de produção nunca grava o instante, e a linha escrita aqui diria "gravado desde o passo 0" com a coluna vazia até a janela. **COPIA AS DUAS TABELAS** (as chaves estrangeiras, como a do fiado): 44 s na cópia em 03/11/2026, as 2,27 milhões de vendas e os 3,26 milhões de itens, com 128 MB de buffer pool. Rode fora do movimento. **Na cópia local desde 03/11/2026**, nos dois bancos (a resposta 83 do dono, a opção (a); o irmão no PostgreSQL em `postgres/`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-caixa-valor-contado.sql`** em produção | acrescenta `caixas.valor_contado`, o dinheiro contado na gaveta ao fechar (a pergunta 15 do dono; a fatia 70, PDV-CANCELAMENTO). Nula em todo caixa. **Só a coluna**: quem a grava e a linha `valorContado` de `colunas_gravadas` entram na fatia 72 (PDV-GAVETA-CONTADA), a linha num passo próprio DEPOIS do código que grava, como a do cancelamento. **Não copia a tabela** (`ALGORITHM=INSTANT`; 0,2 s na cópia). **Na cópia local desde 03/11/2026**, nos dois bancos (a resposta 83 do dono, a opção (a); o irmão no PostgreSQL em `postgres/`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-produtos-estoque-motivo.sql`** em produção | acrescenta `produtos_estoque.motivo` (`uso`, `perda`, `vencimento` ou `acerto`; a pergunta 9 do dono; a fatia 70). Nula em toda movimentação. **Só a coluna**: quem a grava e a linha `motivoDaRetirada` de `colunas_gravadas` entram na fatia 73 (PDV-MOTIVO-DA-RETIRADA), a linha DEPOIS do código. **Não copia a tabela** (`ALGORITHM=INSTANT`; 0,1 s na cópia). **Na cópia local desde 03/11/2026**, nos dois bancos (a resposta 83 do dono, a opção (a); o irmão no PostgreSQL em `postgres/`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-cota-do-pacote.sql`** em produção | acrescenta `produtos.cota_parte_pacote` (o % de cota-parte de quem vendeu o pacote, 0 por padrão: nada muda até alguém configurar), `vendas_produtos.vendedor_do_pacote` (com a chave) e `vendas_produtos.cota_do_vendedor` (a pergunta 16 do dono, o **D310**; a fatia 70). **Só as colunas**: quem as grava e a linha `cotaDoPacote` de `colunas_gravadas` entram na fatia 74 (PDV-COTA-DO-PACOTE), a linha DEPOIS do código. `produtos` não copia (`ALGORITHM=INSTANT`); **`vendas_produtos` COPIA** pela chave estrangeira: 20 s na cópia em 03/11/2026. Rode fora do movimento. **Na cópia local desde 03/11/2026**, nos dois bancos (a resposta 83 do dono, a opção (a); o irmão no PostgreSQL em `postgres/`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-custo-do-item.sql`** em produção | acrescenta `vendas_produtos.custo_unitario`, o custo do cadastro (`produtos.valor_compra`) por unidade, gravado em cada item de produto de estoque na hora da venda (a resposta 224 (b) do dono, de 15/10/2026, "Sim, daqui em diante"; o **D627**; a fatia 74b, PDV-CUSTO-DO-ITEM). Nula em todo item, e nula para sempre no serviço, no pacote e na sessão de pacote (o **D624**). **Só a coluna**: o código que a grava é o da 74b, e a linha `custoDoItem` de `colunas_gravadas` vai DEPOIS dele, no passo 7 da etapa 1 (`colunas-gravadas-custo-do-item.sql`), e não aqui. **Não copia a tabela** (`ALGORITHM=INSTANT`, sem chave): 0,2 s na cópia em 03/11/2026, com 3,26 milhões de itens. **Na cópia local desde 03/11/2026**, nos dois bancos (a resposta 83 do dono, a opção (a); o irmão no PostgreSQL em `postgres/`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-indicacao-porta.sql`** em produção | acrescenta `indicacoes.porta`, por onde o convite de indicação veio (`aplicativo` ou `formulario`; a I3 de `docs/f3-relatorios/spec-indicacao.md` e as respostas 264 (b) e 265 (b) do dono, de 25/10/2026, o **D613** e o **D615**; a fatia 75, REL-INDICACAO-PORTA), `NOT NULL` com o padrão `aplicativo` -- toda linha de antes fica do aplicativo, menos a do formulário com a ligação cujo amigo ainda tem a marca de quem indicou (nenhuma na cópia nem na produção) --, e, **no mesmo roteiro, logo depois, o passado** (a 264 (b): "é dado novo no banco, escrito uma vez, e não paga comissão a ninguém"): cada cliente com a marca "Indicação - <id>" de quem indicou e existe, e sem linha, vira uma linha do formulário (a unidade do amigo, quem indicou, o amigo, a `url` vazia, a `data_criacao` dele), **sem tocar `clientes.indicacao`** e sem `data_venda`, `comissao`, `primeira_venda` nem `data_adesao`; a conferência no fim imprime as linhas por porta e as marcas sem linha (0, salvo as de indicador que não existe, contadas à parte). **Roda com o sistema parado e depois do backup** (a 191 (a): o passo 4 da etapa 1): nenhum amigo nasce pelo código antigo entre o INSERT e o código novo. Na produção a 53 (c) ainda não chegou, e todo amigo do formulário que tiver a marca na hora vira linha (na cópia, 183); o que concluiu o cadastro antes já perdeu a marca, e não volta. A linha `indicacaoDoFormulario` de `colunas_gravadas` vai DEPOIS do código, no passo 7 da etapa 1 (`colunas-gravadas-indicacao-do-formulario.sql`), e não aqui. **Não copia a tabela** (`ALGORITHM=INSTANT`): 0,44 s na cópia em 03/11/2026 com o passado (183 linhas), 0,21 s na segunda vez, sem efeito. **Na cópia local desde 03/11/2026**, nos dois bancos (a 264 (b) e a 83 (a); o irmão no PostgreSQL em `postgres/`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Para desfazer, só o `DROP COLUMN porta`**: as linhas do passado ficam (apagar linha de `indicacoes` pede resposta própria, e cascatearia para os clientes amigos). **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-vendas-indice-cliente-status-data.sql`** em produção | troca o índice `FK_VENDAS_cliente` de `vendas (cliente)` por um maior, **com o mesmo nome**, em `vendas (cliente, status, data_criacao)` (o **D308**; a sua 294 (a), de 04/11/2026, ao **D697**: o índice no passo 0 da etapa 1, e não mais "para a migração final"), num `ALTER` só (`DROP INDEX` e `ADD INDEX`, `ALGORITHM=INPLACE, LOCK=NONE`): a chave estrangeira para `clientes` segue servida pelo prefixo, e em nenhum instante fica sem índice. É o índice com que a Carteira de clientes (a CA1 de `docs/f3-relatorios/spec-carteira.md`) acha a última venda finalizada de cada cliente: sem ele, a conta leva 6,8 a 6,9 s no MySQL, em vez de 1,7 a 1,8 s. **Não apaga linha nem copia a tabela**: 18 s nas 2,27 milhões de vendas da cópia (o D308) e o tempo de 04/11/2026 no banco descartável (o D308). Idempotente: com o índice já nas três colunas (as duas cópias locais, desde 29/09/2026), diz que já está ampliado e não muda nada; com outra forma, para com erro sem mudar nada. O PostgreSQL o tem pelo gerador. **Para desfazer**, o mesmo `ALTER` ao contrário (o de uma coluna, com o mesmo nome); nada depende dele para funcionar, só para ser rápido. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-confirmacoes.sql`** em produção | cria `confirmacoes`, o número de cada confirmação das escritas de dinheiro (**D162**, **D285**; a sua resposta 87, a opção (a), "um número por confirmação"): a tela nova gera um número ao confirmar o pagamento da comanda, a compra ou a devolução de crédito e o repete na nova tentativa (o cabeçalho `Idempotency-Key`), e o servidor grava o número na mesma transação da escrita -- quem confirmou, a unidade, a escrita, o sha256 do pedido sem a senha, e a primeira resposta -- e responde ao número repetido o que respondeu da primeira vez, sem gravar de novo; o índice único (`usuario`, `chave`) enfileira os dois pedidos com o mesmo número que chegam juntos. Só a escrita que deu certo fica; a recusa não grava nada, nem o número. Tabela nova e vazia, sem entidade e sem chave estrangeira: o código pergunta se ela existe (`ISBase\Doctrine\ColunaNoBanco`), e sem ela em produção, que é o que o passo 1 faria se esta linha fosse esquecida, as três escritas gravam como antes do número, sem erro. O código antigo de produção não a lê nem a escreve. Quanto tempo guardar as linhas é a sua pergunta do **D735**; até lá, nada as apaga. **Na cópia local desde 07/11/2026**, nos dois bancos (a resposta 83, a opção (a); o PostgreSQL da cópia por `postgres/migracao-confirmacoes.sql`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-agendas-indice-colaborador-data.sql`** em produção | troca o índice `FK_AGENDAS_colaborador` de `agendas (colaborador)` por um maior, **com o mesmo nome**, em `agendas (colaborador, data)` (o **D189**; a sua 126 (a), *"Pelo passo 0, como o do Desmarcar (a sua 50)"*, e a sua 196 (b), *"O índice entra já na etapa 1"*), num `ALTER` só (`DROP INDEX` e `ADD INDEX`, `ALGORITHM=INPLACE, LOCK=NONE`): a chave estrangeira para `usuarios` segue servida pelo prefixo, e em nenhum instante fica sem índice. É o índice com que a grade de um dia e a listagem por unidade acham o dia sem ler o histórico inteiro de cada colaborador: 241.218 linhas lidas para devolver 189 sem ele; com ele no lugar do de uma coluna, a grade passou de 17,5 s a 0,72 ms e a listagem por unidade de 21,4 s a 95,7 ms na cópia (o D189, com o buffer pool de fábrica). **Não apaga linha nem copia a tabela**: criar o de duas colunas leva de 11 a 20 s nos 4 milhões de linhas (o D189), e a troca inteira levou 25 s na cópia (02/10/2026). Idempotente: com o índice já nas duas colunas (as duas cópias locais, desde 02/10/2026), diz que já está ampliado e não muda nada; com outra forma, para com erro sem mudar nada. O PostgreSQL o tem pelo gerador. **Para desfazer**, o mesmo `ALTER` ao contrário (o de uma coluna, com o mesmo nome); nada depende dele para funcionar, só para ser rápido. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-produtos-estoque-indice-data.sql`** em produção | cria o índice `IDX_PRODUTOS_ESTOQUE_data_criacao` em `produtos_estoque (data_criacao)` (o **D630**, o D-r9 de `docs/f3-relatorios/spec-retirada.md`), com o qual o mês da Retirada de produtos acha as saídas do mês sem ler a tabela inteira. Só desempenho, como o de `id_pai`: nenhum código depende dele. **Só acrescenta e não copia a tabela** (`ALGORITHM=INPLACE, LOCK=NONE`; o tempo na cópia está no D630). **Na cópia local desde 07/11/2026**, nos dois bancos (a resposta 83, a opção (a); o PostgreSQL da cópia por `postgres/migracao-produtos-estoque-indice-data.sql`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. Idempotente; com o nome noutras colunas, para com erro sem mudar nada. **Para desfazer**, `DROP INDEX`. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-nps-envio.sql`** em produção | acrescenta `nps.envio`, o número do envio da pesquisa de satisfação (o **D648**, o D-n1 de `docs/f3-relatorios/spec-nps.md`): o mesmo em todas as linhas de `nps` que um envio do formulário grava (uma por venda avaliada). O relatório de NPS conta a recepção e a experiência uma vez por envio agrupando por ele; nas linhas de antes, nula, o envio segue adivinhado pela cadeia de 60 s. **A entidade mapeia a coluna**: sem ela, toda leitura e gravação de `nps` falha -- condição de subir, como a dos pontos. **Só acrescenta e não copia a tabela** (`ALGORITHM=INSTANT`). O código antigo de produção não a lê nem a escreve. **Na cópia local desde 07/11/2026**, nos dois bancos (a resposta 83, a opção (a); o PostgreSQL da cópia por `postgres/migracao-nps-envio.sql`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Para desfazer, só o `DROP COLUMN envio`**. **Antes do passo 1** — **D323** |
| 0 | **Aplicar `migracao-assistentes-anthropic.sql`** em produção | os assistentes com a API da Anthropic, no lugar do Zapier (a fatia 133, ASSISTENTES-PORTA-DO-CLAUDE; o desenho `docs/f3-telas-restantes/assistentes-anthropic.md`, 2.3; a sua decisão de 07/11/2026, "as fatias de assistentes serão substituídas do zapier para Anthropic", e as suas respostas 366 (c) e 369 (c)): acrescenta a `assistentes` as instruções (`instrucoes`, nula = "ainda sendo preparado", com o conjunto utf8mb4 na própria coluna, para o emoji), o modelo (`modelo`, que nasce `claude-haiku-5-5`), o esforço (`esforco`, `low`), o teto de tokens por resposta (`limite_de_tokens`, 4096) e a página que leva ao assistente (`pagina_publica`, única, nula -- quem a usa é a fatia 89); e cria as quatro tabelas da conversa, em utf8mb4 e sem entidade: `assistentes_conversas` (o `system` e o modelo congelados no primeiro envio), `assistentes_mensagens` (cada mensagem como foi, a resposta com os blocos de raciocínio), `assistentes_cota` (um contador por dia e por chave, a chave primária (`dia`, `chave`)) e `assistentes_uso` (os tokens e o custo estimado de cada pedido, sem conteúdo). As quatro linhas de hoje ficam como estão, e o assistente continua no Zapier até a fatia 88. **Nada apaga estas linhas** (a 369 (c): a conversa fica para sempre; não há rotina de limpeza). O código pergunta se as colunas e as tabelas existem (`ISBase\Doctrine\ColunaNoBanco`), e sem elas em produção, que é o que o passo 1 faria se esta linha fosse esquecida, o assistente da tela nova diz "indisponível", sem erro. O código antigo de produção não as lê nem as escreve. **Na cópia local desde 08/11/2026**, nos dois bancos (a resposta 83, a opção (a); o PostgreSQL da cópia por `postgres/migracao-assistentes-anthropic.sql`), e por isso no laço do ensaio geral; na base do teste do BVIP, na próxima cópia exportada, pela mão do dono. **Para desfazer**, `DROP TABLE` das quatro (as mensagens antes das conversas) e `DROP COLUMN` das cinco, com o índice. **Antes do passo 1** — **D323** |
| 0 | **Não aplicar `sql/sessoes.mysql.sql`** | é a `sessoes`, a sessão do ERP no banco (F1.5), que nenhum ambiente liga: a sessão fica em arquivo (`config/autoload/sessao.global.php`, D198). No PostgreSQL o gerador a cria à mão, sem inquilino e sem RLS, e a sexta prova do passo 5 a aceita só lá. **Aplicada aqui, ela parava a janela no passo 4**: o passo 1 a levaria ao `mysql.sql`, o gerador a emitia duas vezes — a segunda com `inquilino_id` e RLS, que impedem o login —, e o `psql -v ON_ERROR_STOP=1` parava em "already exists" (revisão de 03/10/2026, **D323**). O portão do passo 0 recusa no laço migração que leve ao `mysql.sql` o que o de hoje não tem |
| 0 | **Roda no passo 3 da janela, e não aqui:** `migracao-grupo-lider-gerente.sql` | o grupo 8, Líder, passa a ter a visão de gerente (**D124**, decisão de 09/09/2026). **Quando ela roda foi decidido em 06/10/2026** (lote de perguntas, pergunta 11, a opção (b)): **na janela, no passo 3**, com a aplicação parada e antes do dump. É **dado**, não estrutura: **não está no laço abaixo**, e basta estar no MySQL antes do dump do passo 4. Antes da janela quem a leria é o código antigo, e nele `grupos.gerente` não é só relatório (commit da importação, `eaf1fca`): autoriza venda em fiado sem olhar a unidade de quem autoriza (`VendasPagamentosController::autorizarVendaFiadoAction`), devolve crédito de cliente com a senha de um gerente de qualquer unidade (`ClientesController::creditoDevolucaoAction`), e cancela venda da própria unidade (`VendasController::cancelarAction`). No passo 3 o código antigo nunca a vê; o PostgreSQL nasce com ela, e o código novo dá ao Líder o que dá ao gerente, só na unidade dele |
| 0 | **Roda no passo 3 da janela, e não aqui:** `migracao-privilegios-unicos.sql` | os privilégios repetidos (**D289**, **D374**): as gêmeas de `privilegios_usuarios` juntadas numa linha cada — a mais antiga do par, com o OU das linhas, que é o que o `Acl` já somava —, o molde repetido de `privilegios` com o mais antigo só (também o de valores diferentes: a sua 237 (c), de 17/10/2026), e a chave única no molde, (grupo, funcionalidade), e nas pessoas, (pessoa, funcionalidade). **Decidido em 12/10/2026** (pergunta 125 da página do lote, a opção (a), *"As duas regras, com os 117 pares juntados"*): na cópia local já, nos dois bancos — feito em 17/10/2026: 117 pares, 118 linhas a mais, nos dois —, e no corte no passo 3, com o PostgreSQL nascendo com as duas regras. **Apaga linha**, e por isso **não está no laço abaixo**, que a etapa 1 copia (a resposta 192 (a): só o Líder entra na etapa 1). As duas `UNIQUE KEY` estão no `mysql.sql`, e o portão as aceita por esta linha só com as duas metades (a afirmação 8 dele): esta migração as cria, e o gerador rodado sobre o dump de produção ainda as emite — o passo 1 as tira do `mysql.sql`, e o PostgreSQL as recebe da lista `$unicidades`. Até o passo 3 a produção segue com as gêmeas e sem as chaves, e o código novo funciona assim: soma as linhas ao ler e deixa o par numa linha ao gravar, como desde 29/09/2026. Na base do teste do BVIP, nada a aplicar: a próxima cópia exportada daqui já vem juntada e com as chaves |
| 0 | **Roda no passo 3 da janela, e não aqui:** `migracao-sorteios-vendas-unicos.sql` | os cupons de sorteio repetidos (**D370**, **D561**): o segundo cupom com o mesmo número da mesma venda no mesmo sorteio sai -- 4 na cópia de 16/08/2026, um no #1, um no #3 e dois no #4, de 5 s a 1.419 s um do outro, nenhum deles o ganhador --, e a chave única (sorteio, venda, numero) entra. **Decidido em 12/10/2026** (a resposta 124 (a)) e **em 15/10/2026** (a 218 (a): também nas duas cópias locais, já), e **a regra trocada em 21/10/2026** pelo dono ("trocar a regra do banco"): a venda pode ter vários cupons, e o banco recusa o mesmo número duas vezes. **Apaga linha**, e por isso **não está no laço abaixo**, que a etapa 1 copia (a resposta 192 (a)). **Precisa da coluna `numero`**, que o laço dá (`migracao-sorteios-regra-do-cupom.sql`). A `UNIQUE KEY` está no `mysql.sql`, e o portão a aceita por esta linha com as duas metades (a afirmação 8 dele): esta migração a cria, e o PostgreSQL a recebe da lista `$unicidades` do gerador. Até o passo 3 a produção segue com os 4 pares e sem a chave, e o código novo funciona assim: conta cada cupom (venda e número) uma vez no sorteio, na planilha e nos números, e mostra o repetido à parte. **Na cópia local desde 21/10/2026**, nos dois bancos (primeiro a chave (sorteio, venda), trocada no mesmo dia pela de três colunas). Na base do teste do BVIP, nada a aplicar: a próxima cópia exportada daqui já vem sem os pares e com a chave |
| 0 | **Roda no passo 3 da janela, e não aqui:** `migracao-vipclass-conclusao-unica.sql` | as conclusões repetidas da VIP Class (**D372**): cada envio aprovado do checkpoint gravava uma conclusão nova, e a cópia de 16/08/2026 tem 167 pares (curso, pessoa) com mais de uma, 191 linhas a mais, 132 delas a menos de 60 s da anterior -- o duplo clique. Fica a **primeira** de cada par (a de menor `data` e, no mesmo instante, a de menor `id`), as outras saem, e a chave única (`vipclass`, `usuario`) entra. **Decidido em 15/10/2026** (a pergunta 3 do desenho da VIP Class; a resposta 213 do dono, a opção (a) da página: *"Uma conclusão, vale a primeira -- concluir de novo é recusado; as 191 linhas a mais saem, nas duas cópias locais já e na produção na etapa 2, e o banco passa a recusar a segunda"*). **Apaga linha**, e por isso **não está no laço abaixo**, que a etapa 1 copia (a resposta 192 (a)); o desenho dizia "uma linha no passo 0", e a fila das telas o corrigiu para o passo 3 (a fatia 85, 06/11/2026). A `UNIQUE KEY` está no `mysql.sql`, e o portão a aceita por esta linha com as duas metades (a afirmação 8 dele): esta migração a cria, e o PostgreSQL a recebe da lista `$unicidades` do gerador. Até o passo 3 a produção segue com os pares e sem a chave, e o código novo funciona assim: as telas mostram a primeira, e o envio relê a conclusão sob a trava da pessoa e recusa a segunda; com a chave, a recusa do banco é a mesma frase. **Ainda não está nas cópias locais**: a 213 (a) já autoriza a limpeza nelas (o "nas duas cópias locais já" da opção), mas a fatia 85 não a rodou -- a ordem da fatia era só `SELECT` na `franquiabv_dev` --, e rodá-la nas duas cópias fica com o coordenador (o D728); o irmão do PostgreSQL da cópia é `postgres/migracao-vipclass-conclusao-unica.sql`. Na base do teste do BVIP, nada a aplicar: o código funciona com e sem a chave |
| 0 | **Nada a aplicar:** `unidades.iframe_bi` | saiu do mapeamento com o Power BI (**D9**) e continua em produção, anulável. O passo 1 a traz de volta ao `mysql.sql`, o `schema.sql` a cria, e **a carga a copia**: o passo 4 regera o roteiro de carga daquele `mysql.sql` (**D379**), e o gerador só deixa de fora a `usuarios.senha_php83`. Inofensiva — ninguém a lê. Tirá-la de produção é decisão à parte (**D9**) |
| 1 | **Atualizar `mysql.sql`** com a estrutura de produção | os geradores leem esse arquivo; se ele estiver velho, o schema gerado não é o de produção |
| 2 | Rodar `medir-datas-invalidas.sql` na réplica | varre o catálogo inteiro; foi assim que 2.397 datas inválidas apareceram |
| 3 | Aplicar `pre-migracao-limpeza.sql` | ano 0 e ano 0202 fazem a carga morrer no meio |
| 4 | Uniformizar as 25 entradas do crontab | armadilha conhecida, não defeito de hoje |
| 5 | Rodar `scripts/medir-logins-com-maiuscula.sql` na réplica — **só lê** | no PostgreSQL a comparação tem caixa: quem tem maiúscula gravada no login não entra digitando em minúsculas (**D349**). A consulta escrita no D349 dá **zero no MySQL** com qualquer número de maiúsculas — a collation ignora a caixa; esta compara byte a byte. Os UPDATE dela entram no passo 3 da janela, **e só com o 6 feito**. **Os pares que só diferem pela caixa foram decididos em 06/10/2026** (lote de perguntas, pergunta 8, a opção (b); **D31**, **D295**): no passo 3, o INATIVO de cada par ganha `.inativo<id>` no fim do login — e do e-mail, no par de e-mails — e então a caixa de todos baixa. Par é o que só a caixa separa (o minúsculo comparado byte a byte, nas listagens 3, 4 e 4b e nos `UPDATE`; a 4c fica pela collation, como a entrada do MySQL vê). A listagem **4b** tem de dar zero nas colunas `passariam_do_tamanho` e `ja_existe` (o nome novo que não cabe na coluna, ou que já é de outra linha); a **4c** dá os ids de quem tem **mais de um ativo** no mesmo login ou e-mail, que o passo 3 não toca: **cada um é seu, um a um, antes da janela**. **E os repetidos exatos** — a mesma grafia em duas linhas ou mais — **entram na mesma regra desde 07/10/2026** (pergunta 32 da página do lote, a opção (a), *"A mesma regra para eles"*): a listagem **3b** os conta por estado, o inativo de cada um também ganha `.inativo<id>` no passo 3, e o de dois ativos fica na 4c, com você. **O login com acento espera esta medida** (pergunta 9: a sua resposta foi (d), *decidir depois da medida na réplica*): a coluna `login_com_acento_ou_simbolo`. **Com o número, vale a regra que você escolheu em 07/10/2026** (pergunta 35 da página do lote, a opção (b), *"Siga a regra da recomendação quando medir"*: poucos, nada no código e aviso a cada um; muitos, a comparação sem acento — a (a) e a (c) da pergunta 9). Com zero, nada a fazer. **O número que separa "poucos" de "muitos" não foi dado** — nem na recomendação da pergunta 9, nem na resposta: com o número na mão, a escolha volta a você só se ele ficar na fronteira. O renomear do passo 3 não toca o par de acento |
| 6 | **O código do D349 na versão que sobe no corte** — não é passo de produção | no PostgreSQL a entrada procura o login como veio (o adaptador de `config/autoload/doctrine.global.php`, pela propriedade `login`), e a entidade o grava em minúsculas: sem o código do D349 (a identidade em minúsculas antes de procurar, a F0 de Usuários), quem digita a primeira letra maiúscula — o teclado do celular a põe — não entra, e o teste manual do passo 8 **reprova por construção**. O `UPDATE` do 5 também só vale com ele. Sem o 6, a janela não tem como passar do passo 8. **Feito em 04/10/2026 (o item USUARIOS-F0):** a busca compara o `LOWER` da coluna com o login sem a caixa, e a identidade é o id -- o login gravado com maiúscula também entra, e o `UPDATE` do 5 fica inofensivo, sem ser mais condição da entrada; o par de dois ATIVOS no mesmo login ou e-mail passa a ser recusado com frase, já no MySQL (a listagem 4c do 5 dá os ids). **O acento não:** o MySQL ignora o acento na comparação e o PostgreSQL não -- quem digita "joao" para o "joão" gravado deixa de entrar depois do corte; a coluna `login_com_acento_ou_simbolo` do 5 conta quantos, e o que fazer segue a regra da sua pergunta 35, de 07/10/2026 (poucos, nada no código e aviso a cada um; muitos, a comparação sem acento; o número de corte não foi dado, e na fronteira a escolha volta a você) |
| 7 | Rodar `scripts/medir-nascimento-com-hora.sql` na réplica — **só lê** | as datas de nascimento gravadas com a hora do relógio até o conserto do D194 (`clientes` e `unidades_socios`): a idade do filtro por faixa etária só vira na hora em que o cadastro foi salvo. **Decidido em 06/10/2026** (lote de perguntas, pergunta 12, a opção (a)): medir aqui e, **dando mais que zero**, os dois UPDATE do fim dele entram no passo 3, depois da limpeza. A cópia não serve de medida: o anonimizador regera as datas de nascimento (**D194**). A coluna `anteriores_a_1000` tem de dar zero em `unidades_socios` — a limpeza do passo 3 não passa por ela, e o modo estrito recusa o UPDATE inteiro com uma data assim |
| 8 | **Testar, no servidor de produção, que o aviso das rotinas chega ao log do sistema** — escreve uma linha no log e não muda nada | o passo 9 instala o crontab com `\|\| logger -t rotinas "falhou (curl $?): <a rota>"` em cada linha (**D331**): a rotina que falha (401, 500, o tempo esgotado) escreve no log do sistema, e é lá que alguém olha. **Decidido em 12/10/2026** (a resposta 128 (b), *"Sem e-mail: o aviso vai ao log do sistema"*, que mudou a 31, a do e-mail com teste de envio: o endereço nunca veio). Sem o `logger`, ou com o log do sistema sem guardar o que ele escreve, o aviso não chega a lugar nenhum. Vale antes da janela da etapa 1, que instala o crontab novo (a resposta 194 (a)), e de novo antes desta. O teste está logo abaixo da tabela |
| 9 | Rodar `sh scripts/medir-chamadas-unidade-vendas.sh` sobre os logs de acesso do nginx da produção — **só lê, e só conta** | as três exportações `/api/unidade/vendas`, `vendasV2` e `vendasV3` ficam como estão até se saber quem as chama (pergunta 6, 06/10/2026; **D4**, **D9**), e só o log de acesso da produção sabe. **Decidido em 07/10/2026** (pergunta 33 da página do lote, a opção (a), *"Eu escrevo a conta, para rodar no dia 0"* — só leitura e só contagens, por rota e por dia, sem hash nem IP). O comando, onde fica o log e o que fazer com o número estão logo abaixo da tabela |

**O 8, o teste do aviso no log do sistema** (dia 0, antes da janela — da etapa 1 e
desta; no servidor de produção, como root, que é o dono do crontab das rotinas — o
passo 1 o guarda com `crontab -l`). Nada aqui muda o servidor: o `logger` só
escreve uma linha no log.

```bash
# A MARCA DE AGORA: o 8 roda antes da etapa 1 e de novo antes da etapa 2, e a linha do teste de antes segue no log;
# contar so o texto fixo a contaria, e daria "chegou" com o caminho do logger quebrado depois (a verificacao da 113)
MARCA="teste do aviso das rotinas (franquiabv, $(date +%s)-$$)"
ls -l /usr/bin/logger || echo "SEM O logger: o aviso das rotinas nao teria onde ir"
/usr/bin/logger -t rotinas "$MARCA" && echo "ESCRITO: a contagem de baixo diz se chegou"
sleep 2
# onde o aviso chega: o journal e o log do sistema -- o zgrep le tambem os girados em .gz, que o grep leria como binario
{ journalctl -t rotinas --since '-10 min' 2>/dev/null; zgrep -h ' rotinas' /var/log/syslog* 2>/dev/null; } | grep -cF "$MARCA"
```

A linha do crontab novo chama o mesmo `logger` (o `cron` do Ubuntu roda com
`/usr/bin` no caminho), com a mesma marca `rotinas`. Lendo o resultado:

| O teste dá | Quer dizer |
|---|---|
| `ESCRITO`, e a contagem **maior que zero** (1 ou 2: o journal e o `/var/log/syslog` podem ter, cada um, a mesma linha; só a deste teste conta, pela marca com a hora e o número do processo) | o log do sistema guarda o aviso: é onde se olha depois do corte — `journalctl -t rotinas`, ou `zgrep -h ' rotinas' /var/log/syslog*` —, e cada linha `falhou` é uma rotina que não rodou |
| `SEM O logger`, ou a contagem em **zero** | o aviso não chegaria a lugar nenhum: instalar o `logger` ou mudar o que o log do sistema guarda é mudança no servidor, e é sua (a decisão de 11/09/2026): **volta a você antes da janela** — não se faz por conta própria |

**Ensaiado** por `sh scripts/ensaiar-crontab-do-passo-9.sh`, que roda este
bloco com um `logger` e um `journalctl` de mentira e um log do sistema
inventado: com o `logger`, escreve e conta 2 (a linha do log e a do journal);
sem ele, diz `SEM O logger` e conta zero; e **a linha de um teste de antes não
conta** — nem a do girado `.gz`, nem a do journal, nem a do log de agora: rodado
de novo com um `logger` que sai 0 sem escrever nada (o caminho quebrado entre a
etapa 1 e a 2), diz `ESCRITO` e conta zero.
**Não provado:** o servidor de produção, que nunca foi lido daqui, e quem lê o
log depois do corte — a 128 (b) diz *"alguém tem de olhar o log"*, e quem é,
não está escrito.

**O 9, quem chama as três `unidadeVendas*`** (dia 0, antes da janela; quem tem
acesso de root à produção). O log fica em `/var/log/nginx/`, **um por vhost**
(**D36**: a coleta que leu só o `access.log*` achou zero pedidos a uma rota que o
aplicativo chama a cada abertura) — por isso todos os `*.log` e os girados
`*.log.*`, com os `.gz`. Os nomes dos arquivos de lá não estão no repositório
(**D332**): olhe antes. **Nada vai para o servidor**: o roteiro segue pela
entrada do `ssh`, e a saída fica na sua máquina.

```bash
ssh root@<a producao> 'ls -l /var/log/nginx/'                       # quais arquivos, e de quando o mais velho
ssh root@<a producao> 'sh -s -- /var/log/nginx/*.log /var/log/nginx/*.log.*' \
    < scripts/medir-chamadas-unidade-vendas.sh > chamadas-unidade-vendas.txt; echo "saida $?"
```

A saída só tem contagens: por arquivo, por dia, por caminho — sem IP, sem `hash`,
sem query string, sem user-agent (o cabeçalho do roteiro diz por quê). O que
fazer com ela:

| A conta dá | O que fazer |
|---|---|
| `saida 2` | **não mediu** — leia a última linha: o controle (`/api/versao/app`) em zero é log errado; um arquivo que não leu é o glob sem arquivo ou falta de root. Não leve zero ao dono |
| `saida 0`, as três em zero | ninguém as chamou **nos dias da JANELA** (a apuração do dia 0 achou 14 dias de log) — o que não quer dizer que ninguém chama: uma exportação mensal escapa de duas semanas |
| `saida 0`, as três acima de zero | alguém chama. O ritmo por dia diz se parece integração agendada (todo dia, na mesma quantidade) ou avulso; saber **quem** pede o IP ou o user-agent, que esta conta não lê de propósito — é outra pergunta |

Nos três casos de `saida 0`, a tabela e a JANELA vão para o **D4** e o **D9** (só
os números), e **a sua pergunta 6 volta a você com eles**: as três ficam como
estão até lá. O `/index.php/` na frente e a URL inteira no pedido (`GET
http://<host>/api/unidade/vendas`) **contam como as três**: as duas formas chegam
à exportação (200 no nginx daqui, medido em 08/10/2026). Os "parecidos" (a barra
no fim, a barra dobrada, outra caixa, a `vendasV4` — 404 no nginx daqui) não são
as três: são alguém tentando o caminho vizinho. Ensaio no repositório, com um log
inventado, que roda também o comando do `ssh` acima sobre ele:
`sh scripts/ensaiar-medir-chamadas-unidade-vendas.sh`.

**O PASSO 0 TEM PORTÃO desde 03/10/2026 (D323).** Até 02/10 ele listava três
migrações, e o `mysql.sql` tinha mudado em mais lugares: `clientes_sessoes`,
`pontuacoes_historicos.autorizado_por` e as três `unidades.hash_anterior*`
entraram sem migração, e a do Líder não aparecia aqui.
`php scripts/conferir-passo-zero-do-corte.php` compara o `mysql.sql` de hoje com
o do dump de produção (commit `3b1f05c`) e exige, para cada diferença, uma
migração **do laço abaixo** que a faça; que o laço seja a lista das linhas
`| 0 | **Aplicar …**`, na mesma ordem; que nada do laço leve ao `mysql.sql` o que
o de hoje não tem; e que toda migração do repositório (`scripts/migracao-*.sql`,
`sql/*.sql`) esteja citada aqui — as que não começam por "Aplicar" citam sem
aplicar. **E, desde 09/10/2026, que o laço da cópia do desenvolvimento**, no
`ensaio-geral-do-corte.md`, seja este, na mesma ordem, menos as migrações cujo
cabeçalho diz "NAO rode na copia local": quem prepara o ensaio copiava este laço, e
ele criava o índice de `id_pai` (**D220**) na `franquiabv_dev` sem a autorização do
dono. Desde a noite de 09/10/2026 nenhuma diz: o dono autorizou o índice na cópia
local, nos dois bancos (pergunta 62 do lote, a opção (b)), ele foi criado nela, a
frase saiu do cabeçalho e os dois laços são o mesmo, com as sete. **De 10 a
11/10/2026 uma disse de novo**: a dos aparelhos do push (`migracao-clientes-aparelhos.sql`,
**D461**), até o dono responder se a tabela entrava já na cópia local. Ele autorizou
(a resposta 72 do lote, a opção (c)), a tabela foi criada nos dois bancos da cópia na
manhã de 11/10/2026, a frase saiu do cabeçalho, e os dois laços voltaram a ser o
mesmo, com as oito. **De 11 a 12/10/2026 uma disse de novo**: a dos cupons
(`migracao-cupons-clientes-desconto-produto.sql`, **D271**), até o dono responder a
pergunta 83 do lote (o esquema em bloco nas cópias locais). Ele respondeu na madrugada
de 12/10/2026, com a opção (a) -- tabela, coluna e índice que não apagam linha entram
nas duas cópias locais quando a resposta pedir --, as duas colunas foram criadas nos
dois bancos da cópia, a frase saiu do cabeçalho, e os dois laços voltaram a ser o
mesmo, com as nove. **A décima entrou em 12/10/2026 já sem a frase**: a do "Não" do PDV
antigo (`migracao-vendas-produtos-pacote-recusado.sql`, **D453**), uma coluna que só
acrescenta, pela regra da resposta 83 -- criada nos dois bancos da cópia no mesmo dia,
e os dois laços com as dez. **A décima primeira entrou em 13/10/2026, também sem a frase**: a dos motivos e dos
autores de Contas a Pagar (`migracao-saidas-motivos-e-autores.sql`, **D205**, **D207** e **D199**), oito colunas
que só acrescentam, criadas nos dois bancos da cópia no mesmo dia, e os dois laços com as onze. **A décima
segunda entrou no mesmo dia, também sem a frase**: a da vigência da taxa de royalty (`migracao-royalties-aliquotas.sql`,
**D368**), uma tabela que só acrescenta, criada nos dois bancos da cópia no mesmo dia, e os dois laços com as doze. **A décima terceira entrou em 15/10/2026, também sem a frase**: a do registro da devolução de
crédito (`migracao-clientes-creditos-devolucoes.sql`, **D179**), uma tabela que só acrescenta, criada nos dois bancos da
cópia no mesmo dia, e os dois laços com as treze. **A décima quarta entrou em 21/10/2026, também sem a frase**: a da regra do
cupom de sorteio (`migracao-sorteios-regra-do-cupom.sql`, **D561**), três colunas que só acrescentam, criadas nos dois bancos
da cópia no mesmo dia, e os dois laços com as catorze.
**E, desde 17/10/2026, a chave única que só o passo 3 cria** (as duas dos privilégios repetidos, a resposta 125 (a),
**D289** e **D374**): elas estão no `mysql.sql`, e não no laço -- a migração apaga linha antes de criá-las --, e o portão
as aceita pela linha `| 0 | **Roda no passo 3` dela só com as duas metades: a migração as cria com as mesmas colunas, e o
gerador rodado sobre o dump de produção (`--mysql-sql`) ainda as emite, porque o passo 1 as tira do `mysql.sql`. Um índice
comum ou uma coluna nunca é coberto assim.
Roda no `conferir-tudo.sh`.

As seis do laço são idempotentes e foram ensaiadas em 03/10/2026, **duas vezes
cada**, num banco descartável montado do dump de produção: depois delas, a
estrutura bate com a de um banco montado do `mysql.sql` de hoje em toda coluna
(com a ordem), índice, chave estrangeira e opção de tabela, menos a `iframe_bi`,
e `conferir-migracao.php` passa as seis provas contra o schema gerado daquela
estrutura (ver **D323** e o passo 5). A do Líder também, à parte (é dado).
**A sétima, a do índice de `id_pai` (D220), entrou em 09/10/2026** e foi ensaiada
do mesmo jeito, num banco descartável montado do dump com uma agenda inventada de
400 mil linhas: o laço inteiro duas vezes (na segunda ela diz "indice ja existe"),
e a `agendas` saiu igual à de um banco montado do `mysql.sql` de hoje em toda
coluna, índice e chave estrangeira; a busca da família passou de ler as 400 mil
linhas ao `index_merge` da `PRIMARY` com o índice novo. O `conferir-migracao.php`
não rodou de novo: um índice não muda dado. **A oitava, a dos aparelhos do push
(D461), entrou em 10/10/2026** e foi ensaiada do mesmo jeito, num banco descartável
montado do dump: o laço inteiro duas vezes, sem erro (na segunda, o `CREATE TABLE IF
NOT EXISTS` não faz nada), e a `clientes_aparelhos` saiu igual à de um banco montado
do `mysql.sql` de hoje em toda coluna (com a ordem, e o `ascii_bin` do identificador),
índice, chave estrangeira e opção de tabela. Ensaiada de novo no mesmo dia, do mesmo
jeito, depois que a verificação acrescentou a coluna `telefone` (o da sessão que
registrou o aparelho): as duas rodadas sem erro, e a estrutura igual. O
`conferir-migracao.php` não rodou: a tabela nasce vazia. **A décima, a do "Não" do PDV
antigo (D453), entrou em 12/10/2026** e foi ensaiada do mesmo jeito, num banco
descartável montado do dump (`git show 3b1f05c:mysql.sql`): o laço inteiro, as dez,
duas vezes, as vinte rodadas com saída 0 (na segunda, cada uma diz que já existe), e a
estrutura do banco inteiro -- coluna (com a ordem, o tipo, o padrão e o comentário),
índice, chave estrangeira e opção de tabela, 1.410 linhas do `information_schema` --
igual à de um banco montado do `mysql.sql` de hoje (o `franquiabv_cilocal`), menos a
`iframe_bi`, que só desloca a ordem das colunas de `unidades`. O banco descartável foi
apagado em seguida. O `conferir-migracao.php` não rodou: a coluna nasce 0 em toda
linha. **A décima primeira, a dos motivos e dos autores de Contas a Pagar (D205, D207 e D199), entrou em
13/10/2026** e foi ensaiada do mesmo jeito, num banco descartável montado do dump (`git show 3b1f05c:mysql.sql`):
o laço inteiro, as onze, duas vezes, as 22 rodadas com saída 0 (na segunda, cada uma diz que já existe), e a
estrutura do banco inteiro -- 1.419 linhas do `information_schema`: coluna (com a ordem, o tipo, o padrão e o
comentário), índice, chave estrangeira e opção de tabela -- igual à de um banco montado do `mysql.sql` de hoje
(o `franquiabv_cilocal`), menos a `iframe_bi`, que só desloca a ordem das colunas de `unidades`. O banco
descartável foi apagado em seguida. O `conferir-migracao.php` não rodou: as colunas nascem nulas em toda linha.
**A décima segunda, a da vigência da taxa de royalty (D368), entrou no mesmo dia** e foi ensaiada do mesmo jeito, num banco
descartável montado do dump (`git show 3b1f05c:mysql.sql`, `royalties_descartavel_passo0`, às 08:46 de 13/10/2026): o laço
inteiro, as doze, duas vezes, as 24 rodadas com saída 0 (na segunda, a tabela nova não faz nada: `CREATE TABLE IF NOT
EXISTS`), e a estrutura do banco inteiro -- 1.520 linhas do `information_schema`: coluna (com o tipo, a nulabilidade, o
padrão, o conjunto, a comparação, o comentário e a ordem), índice, chave estrangeira e opção de tabela -- igual à do
`franquiabv_cilocal`, menos a `iframe_bi` e a `_fixtures_versao`, que só o banco da suíte tem. O banco descartável foi
apagado em seguida. O `conferir-migracao.php` não rodou: a tabela nasce vazia.
**A décima terceira, a do registro da devolução de crédito (D179), entrou em 15/10/2026** e foi ensaiada do mesmo jeito,
num banco descartável montado do dump (`git show 3b1f05c:mysql.sql`, `devolucoes_descartavel_passo0`, às 22:10 de
15/10/2026): o laço inteiro, as treze, duas vezes, as 26 rodadas com saída 0 (na segunda, a tabela nova não faz nada:
`CREATE TABLE IF NOT EXISTS`), e a estrutura do banco inteiro -- 1.445 linhas do `information_schema`: coluna (com o
tipo, a nulabilidade, o padrão, o conjunto, a comparação, o comentário e a ordem), índice, chave estrangeira (com as regras)
e opção de tabela -- igual à do `franquiabv_cilocal` (1.444), menos a `iframe_bi`, que só desloca a ordem das colunas de
`unidades`. O banco descartável foi apagado em seguida. O `conferir-migracao.php` não rodou: a tabela nasce vazia.
Na ordem da tabela:

```bash
for f in scripts/migracao-pontuacao-validade-unidade.sql scripts/migracao-venda-autorizacao-fiado.sql \
         scripts/migracao-usuarios-horarios.sql scripts/migracao-clientes-sessoes.sql \
         scripts/migracao-pontuacao-resgate-autorizado.sql scripts/migracao-unidade-hash-anterior.sql \
         scripts/migracao-agendas-indice-id-pai.sql scripts/migracao-clientes-aparelhos.sql \
         scripts/migracao-cupons-clientes-desconto-produto.sql scripts/migracao-vendas-produtos-pacote-recusado.sql \
         scripts/migracao-saidas-motivos-e-autores.sql scripts/migracao-royalties-aliquotas.sql \
         scripts/migracao-clientes-creditos-devolucoes.sql scripts/migracao-sorteios-regra-do-cupom.sql \
         scripts/migracao-arquivos-acessos.sql scripts/migracao-unidades-whatsapp-eventos.sql \
         scripts/migracao-colunas-gravadas.sql scripts/migracao-venda-cancelamento.sql \
         scripts/migracao-caixa-valor-contado.sql scripts/migracao-produtos-estoque-motivo.sql \
         scripts/migracao-cota-do-pacote.sql scripts/migracao-custo-do-item.sql \
         scripts/migracao-indicacao-porta.sql scripts/migracao-vendas-indice-cliente-status-data.sql \
         scripts/migracao-confirmacoes.sql scripts/migracao-agendas-indice-colaborador-data.sql \
         scripts/migracao-produtos-estoque-indice-data.sql scripts/migracao-nps-envio.sql \
         scripts/migracao-assistentes-anthropic.sql; do
    echo "== $f"; mysql franquia_producao < "$f" || break
done
```

**A do Líder fica fora do laço: ela roda no passo 3 da janela** (a linha dela,
acima; decisão de 06/10/2026). Se um dia ela voltar para antes da janela, a
linha passa a começar por **Aplicar** e ela entra no fim do laço — o portão
exige os dois.

**CINCO DELAS COPIAM A TABELA:** a de `vendas`, a de `pontuacoes_historicos`,
desde 11/10/2026 a de `cupons_clientes` (**D271**) e, desde 03/11/2026, a do cancelamento
(`vendas` e `vendas_produtos`, 44 s na cópia) e a da cota do pacote (`vendas_produtos`,
20 s) acrescentam chave estrangeira, e
com `foreign_key_checks` ligado o MySQL só faz isso pelo algoritmo COPY (pedido
`INPLACE`, ele recusa — medido no 8.4): as 2,2 e 1,2 milhões de linhas, e as 100 mil
dos cupons, são reescritas, e as escritas na tabela — nos cupons, a emissão e a
queima do fechamento da venda — esperam até o fim. Rode fora do movimento. **A do índice de `id_pai` não copia:** índice secundário
sem chave estrangeira entra `INPLACE`, com `LOCK=NONE`, e as escritas na agenda
seguem enquanto ele nasce (de 11 a 20 s nos 4 milhões de linhas, pela medida do
**D189**); se o servidor recusar o `INPLACE`, o `ALTER` falha sem mudar nada.

**ERAM CINCO.** O passo que mandava inventariar as consultas do Power BI saiu em
09/09/2026 junto com o próprio Power BI (**D9**) — era o único aqui que dependia
de terceiro, e o corte perdeu essa amarra. **E voltaram a ser cinco em
03/10/2026**, com a medida dos logins com maiúscula (o 5), **e seis no mesmo
dia**, com o código do D349 (o 6), que a revisão achou pressuposto pelo passo 3 e
pelo passo 8 sem estar aqui. **E sete em 06/10/2026**, com a medida das datas de
nascimento com hora (o 7), que o dono decidiu medir antes e acertar na janela.
**E nove em 08/10/2026**, com o teste do e-mail do cron (o 8) e a conta de quem
chama as `unidadeVendas*` (o 9), que o dono decidiu nas perguntas 31 e 33 da
página do lote (07/10/2026). Os dois só leem, como o 2, o 5 e o 7. **O 8 virou o
teste do log do sistema em 07/11/2026** (a resposta 128 (b), o **D331**): escreve
uma linha no log, e nenhum endereço falta mais.

### Preparação, sem tocar em produção

1. **Provisionar o PostgreSQL** — versão, fuso, `search_path`, e as réplicas.
2. ~~**Repontar o Power BI**~~ — **desnecessário desde 09/09/2026**: o Power BI
   saiu do sistema por decisão do dono (**D9**). Era o item desta lista que não
   era trabalho de banco e que costumava demorar mais que o esperado.
3. ~~**Decidir o D43**~~ — **feito em 31/08/2026**: a rotina
   `yungasNpsAtualizar` foi removida, com a ação e a rota. Nada a fazer aqui.
4. **O php-fpm de produção, com os três requisitos já decididos** (**D67**,
   decidido pelo dono em 31/08/2026: "fica para o desenho do corte"; escrito
   aqui em 11/10/2026 para o corte não os esquecer). A configuração do php-fpm
   da produção não está no repositório e não é conhecida daqui; **aplicar é do
   dono**, no servidor, antes da janela. Nesta ordem de importância:

   | | o requisito | por quê |
   |---|---|---|
   | 1 | `request_terminate_timeout` **acima de 21 minutos** — `1800s` serve | é o único teto que mata um `curl` pendurado: quem o aplica é o processo mestre, e não o script. A rotina mais longa, `relatoriosDashboardColaboradoresAtualizar`, levou 21 min na base local; um teto abaixo disso a mata toda noite. *Ela saiu do código em 13/10/2026 (a resposta 137 do dono), e a mais longa das que ficaram não foi medida: os 1800 s continuam servindo, e baixá-los pede a medida antes* |
   | 2 | **um pool separado para as rotinas de cron**, e o do aplicativo com um teto curto | o teto é do pool inteiro: com um pool só, cinco rotinas penduradas ocupam os 5 workers por meia hora, e o aplicativo dos clientes divide esse pool (`marketingPush` roda a cada minuto das 8h às 22h) |
   | 3 | `pm.max_children` acima de 5 — **nunca sozinho** | mais workers não são teto: uma chamada infinita continua infinita, e o esgotamento só demora mais. Acompanha o 1 e o 2, nunca os substitui |

   **E o que não serve, para ninguém tentar de novo:** `max_execution_time` não
   conta o tempo fora do script (banco, socket, rede) e não mata o `curl`
   pendurado. O crontab dá 600 s àquela rotina de 21 min: o `curl` do agendador
   desiste, o worker continua, e ninguém fica sabendo — o limite de cima protege
   quem chama, nunca o processo. As nove chamadas de rede do código já têm tempo
   limite (o `conferir-tempo-limite-rede.php`); o teto do php-fpm é a rede para a
   próxima que nascer sem ele.
5. **O `/arquivos/` fechado no nginx da produção** (**D355**; 22/10/2026, a fatia
   ARQUIVOS-SERVIDOR; a resposta 135 (a) do dono: "como hoje", e o endereço direto
   fecha com qualquer resposta). O nginx daqui já fecha (`docker/nginx/default.conf`,
   o "Bloco de segurança 0"); o da produção não está no repositório (**D12**) e
   serve `/arquivos/<nome>` a quem tiver o nome, sem sessão. **Entra na etapa 1**,
   no vhost novo do passo 8 de `docs/procedimento-de-corte-etapa-1.md` — o código
   que entrega o arquivo só com sessão (`EntregaDoArquivo`, pela `/adm/api` e pelo
   "abrir" do ERP) vai junto. Aqui, na etapa 2, só se confere que continua:
   `nginx -T 2>/dev/null | grep -n -A1 'location ~\* \^/arquivos/'` mostra o bloco
   com o `return 404;`, sem caixa e antes de toda outra `location ~` (o `^~` de
   antes deixava passar `/ARQUIVOS/<nome>`; a verificação de 22/10/2026) —
   **nunca um `curl` com um nome de arquivo de verdade**, que
   deixaria o nome no log. A volta não precisa reabrir: o código de antes também lê
   o arquivo pelo `Armazenamento` (o D296), e não pela URL.
6. **"Clientes crédito" com quem compra e devolve crédito no balcão** (**D123**;
   07/11/2026, a fatia 104; a resposta 99 (a) do dono: "Pedir 'Clientes crédito'
   também no ERP e no servidor"). Comprar e devolver crédito passaram a pedir a
   permissão além do cadastro. **Entra na etapa 1**, com o código: o item 13 de
   "O que tem de estar pronto antes de a janela começar" de
   `docs/procedimento-de-corte-etapa-1.md` tem a contagem, só leitura, de quem
   compra crédito sem ela. Dar a permissão é operação de produção, do dono, antes
   de o código subir.

### O ensaio geral

**Duas execuções completas, cronometradas, com resultado idêntico.** Enquanto os
números variarem, a janela não pode ser dimensionada.

O roteiro está em **`ensaio-geral-do-corte.md`** e é executável:

```bash
SENHA_APP='…' sh scripts/ensaiar-corte.sh primeira
SENHA_APP='…' sh scripts/ensaiar-corte.sh segunda
sh scripts/comparar-ensaios.sh ensaios/primeira.txt ensaios/segunda.txt
```

Cada execução cronometra as fases, grava uma impressão digital da origem e
do resultado, e **ensaia a volta atrás no fim** — apontando de volta para o
MySQL e rodando a suíte de caracterização de novo. O comparador **anula** o ensaio se a
origem tiver mudado entre as duas execuções, em vez de reportar diferença de
tempo sobre bases diferentes.

**Com uma cópia real da produção, as duas fases da suíte morriam**: ela exige as
fixtures, que a cópia do desenvolvimento tem e a da produção não (**D378**).
Desde 11/10/2026 a suíte roda em clones dos dois bancos, com as fixtures só
neles (as fases `clone_pg` e `clone_mysql`); o `ensaio-geral-do-corte.md` diz
como, e o que não foi rodado. A cópia precisa do passo 0 aplicado antes, como a
produção terá.

O ensaio roda contra uma cópia do tamanho de produção, no **formato de hardware
de produção** — 4 vCPU, não as 24 do BVIP. `ambiente-homologacao.md` traz o
limite de contêiner para isso. Sem esse limite, o número sai otimista por um
fator grande.

---

## A janela

A ordem importa mais que a velocidade. Cada passo tem um sinal de que deu certo.

### 1. Parar o que escreve — o cron primeiro

```bash
crontab -l > /root/crontab-antes-do-corte.txt   # guarde
crontab -r
pgrep -af 'php .*public/index.php' || echo "nenhuma rotina em curso"
```

**Este é o passo mais fácil de esquecer e o mais caro.** São 25 entradas. Uma
rotina que dispare depois do dump grava no MySQL um dado que nunca chegará ao
PostgreSQL — e ninguém vai procurá-lo, porque ninguém sabe que ele existiu.

Só depois de o cron estar parado e nenhuma rotina em curso é que faz sentido
parar a aplicação.

### 2. Parar a aplicação

Página de manutenção, ou parar o PHP. Confirme que ninguém escreve:

```sql
SHOW PROCESSLIST;   -- nenhuma conexão da aplicação em Query
```

### 3. Limpar o que o PostgreSQL recusa

```bash
mysql franquiabv < scripts/pre-migracao-limpeza.sql
```

**Imediatamente antes do dump**, não na véspera: o script é idempotente e barato,
e uma linha nova com data inválida entre a limpeza e o dump derruba a carga.

**Os logins e os e-mails: os pares, e depois a caixa** (**D349**, **D31**,
**D295**; os pares decididos em 06/10/2026, lote de perguntas, pergunta 8, a
opção (b)): os **quatro** `UPDATE` do fim de `scripts/medir-logins-com-maiuscula.sql`,
descomentados e **nessa ordem**. Os dois primeiros renomeiam o **inativo** de cada
par que só difere pela caixa — o login, no par de logins, e o e-mail, no par de
e-mails, ganham `.inativo<id>` no fim —, e o par some; os dois de baixo baixam a
caixa de quem ficou sem par e é todo ASCII, o que inclui o ativo do par desfeito
e os renomeados. **E o repetido exato também** (decidido em 07/10/2026, pergunta
32 da página do lote, a opção (a), *"A mesma regra para eles"*; **D31**,
**D295**): o login, ou o e-mail, com a **mesma grafia** em duas linhas ou mais
tem o inativo renomeado do mesmo jeito — os dois primeiros `UPDATE` agrupam pelo
minúsculo com "mais de uma linha", e não mais "mais de uma grafia", e o e-mail
vazio fica fora do grupo. A listagem **3b** do 5 de "O que hoje bloqueia" conta
os repetidos exatos por estado. **"Par" é só a caixa:** a chave é o minúsculo comparado byte a
byte, e não a collation, que ignora também o acento e o espaço do fim — até a
revisão de 06/10/2026 era a collation, e o inativo de um par "paulo"/"páulo"
também ganhava `.inativo<id>`, sem decisão para isso. **Ninguém ativo muda de login.** Quem tem **mais de um ativo** no
mesmo login ou e-mail — o par de caixa ou o repetido exato — não é tocado: a
listagem 4c do 5 de "O que hoje bloqueia" deu os ids antes da janela, e cada um
é seu. Rodar de novo não muda nada. **Ensaio no repositório**, `php
scripts/ensaiar-sql-do-passo-3.php` (o fim deste passo), com usuários inventados
— o par de um ativo, o de dois inativos, o de dois ativos, o de acento, os
repetidos exatos (de login e de e-mail; com um inativo, com dois ativos, com dois
inativos, com um ativo e dois inativos), o grupo de caixa e exato juntos, os pares
de e-mail e o e-mail vazio, ativo e inativo: 12, 4, 6 e 2 linhas, cada usuário
conferido depois, as listagens 3b, 4, 4b e 4c conferidas antes, e nada na segunda
vez, no 8.4.11 e no 8.0.46 (o da réplica). E o **índice único do D295** fica possível depois: o que
ainda se repete, pelo minúsculo byte a byte, são só os ativos da 4c, e sem eles
o índice nasce no banco descartável do ensaio. **Só com os zeros da 4b:** com o
nome novo maior que a coluna, o modo estrito recusa o primeiro `UPDATE` inteiro
(ensaiado: nada muda, e o `mysql` para ali), e fora do modo estrito o MySQL
cortaria o fim. **Só junto com o código do D349 no ar** (o 6 de "O que hoje
bloqueia": a identidade em minúsculas antes de procurar): o dado sem o código
troca quem não entra, e não resolve. Os logins **com acento** ficam como estão
até a medida do 5 (pergunta 9), e a regra do que fazer com o número é a sua
pergunta 35, de 07/10/2026: poucos, nada no código e aviso a cada um; muitos, a
comparação sem acento (o 5 de "O que hoje bloqueia"). No MySQL baixar a
caixa é inofensivo — a collation já ignorava a caixa; o renomear muda o login
de quem está inativo, que não entra de qualquer jeito.

**As datas de nascimento com hora, se o 7 de "O que hoje bloqueia" deu mais que
zero** (**D194**; decisão de 06/10/2026, pergunta 12): os dois `UPDATE` do fim de
`scripts/medir-nascimento-com-hora.sql`, descomentados, **depois** da limpeza
acima — ela anula as datas de `clientes.data_nascimento` anteriores a
1000-01-01, que o modo estrito recusaria. Eles tiram a hora e deixam o dia, e
rodar de novo não muda nada — no mesmo ensaio, com datas inventadas: 2 e 1
linhas, nada na segunda vez, e a `data_alteracao` não muda.

**O Líder** (**D124**; decidido em 06/10/2026, pergunta 11: aqui, na janela):
com a aplicação parada e antes do dump,

```bash
mysql franquiabv < scripts/migracao-grupo-lider-gerente.sql   # confira antes o SELECT do cabeçalho dela
```

**O dicionário de CEP sai daqui vazio, e as 27 UFs de novo** (**D74**, **D120**,
**D430**; decidido em 06/10/2026, lote de perguntas, pergunta 10, a opção (b):
*"Sim, no passo 3, antes do dump. Utilize a api do ViaCEP para popular as
informações."*).
`logradouros`, `bairros`, `cidades` e `estados` viraram cache do ViaCEP em
31/08/2026, e o esvaziamento só tinha rodado no desenvolvimento: a produção,
congelada desde 23/08, ainda tem o dicionário — 709.440 logradouros na última
cópia, que é o do D72 (até 37 logradouros por CEP). Aqui, depois da limpeza e
antes do dump:

```bash
mysql franquiabv < scripts/limpar-dicionario-cep.sql   # mostra as quatro contagens antes e depois: depois, 0 nas tres primeiras e 27 em estados
```

**O que ele apaga não volta** — não há de onde recarregar o dicionário — e
sobrevive à volta atrás. A carga fica cerca de 758 mil linhas menor, e o
PostgreSQL nasce com o cache vazio. **Os 27 estados voltam no mesmo roteiro**
(revisão de 06/10/2026, **D430**). Nenhuma tabela aponta para as quatro por
chave estrangeira — o endereço de cliente, usuário, unidade e sócio é texto —,
mas o ERP antigo monta de `estados` a lista do campo obrigatório *Estado
(`EstadoRepository::popularCombobox`: o cadastro e a edição de cliente, os
modais do PDV e da agenda, fidelidade, unidades, o cadastro de usuário e o filtro dos
relatórios e das campanhas). Com a tabela vazia, o select vem sem opção, o
`ClienteFilter` recusa o cliente brasileiro sem estado, e a busca de CEP grava a
UF no banco sem conseguir escolhê-la na página aberta: o primeiro cliente de
cada UF não se cadastrava pelo ERP antigo no primeiro dia. É o que o
desenvolvimento tem desde 31/08 — quatro UFs, as dos CEPs consultados. O
roteiro apaga e põe de volta as 27 de `RegraDoCadastroDeCliente::UFS`, a lista
da tela nova, e a busca de CEP acha a UF pelo `uf`, sem criar outra.
**Confirmado em 07/10/2026** (pergunta 34 da página do lote, a opção (a),
*"Apagar e repor as 27 UFs no mesmo roteiro"*): é o que o roteiro já fazia. Em
`ensaiar-sql-do-passo-3.php`: 0, 0, 0 e as 27 UFs iguais às da tela nova, e o
mesmo na segunda vez. O tempo, no tamanho de produção, está em "Quanto tempo a
janela precisa". **O cache de endereços volta a encher sob demanda**, decidido
em 07/10/2026 (pergunta 30 da página do lote, a opção (a), *"Sob demanda, como o
código já faz"* — *"Nada a construir."*): ele se enche sozinho a cada CEP
consultado na tela, pelo ViaCEP (890 ms na primeira vez, 3 ms depois, medido no
D74). Não há pré-carga, nem antes nem depois do corte.

**"Vendas caixa" sai da lista** (**D227**; decidido em 12/10/2026, a resposta 122
do dono, a opção (a), *"Sai da lista"*): a permissão não governa nada desde
26/09/2026 — abrir, retirar e fechar o caixa pedem "Vendas nova", no ERP e na
tela nova —, e a lista de permissões não tem como marcar um item fora de uso. Aqui,
depois da limpeza e antes do dump:

```bash
mysql franquiabv < scripts/remover-funcionalidade-vendas-caixa.sql   # mostra antes a funcionalidade, os moldes de grupo e as linhas de pessoas que saem; depois, zero nas tres
```

**Só junto com o código de 12/10/2026 no ar** (o mesmo deploy): o `Acl` recusa
com exceção a funcionalidade que não existe, e o `SessaoAcl` de antes lia "Vendas
caixa" em todo login — com o roteiro e o código velho, ninguém entraria. Desde
12/10/2026 a sessão não a lê, e a tela nova não a mostra nem a grava (o catálogo,
os privilégios da pessoa e o molde do grupo); a tela antiga de permissões a mostra
até aqui. **Ninguém perde o que faz hoje:** ela só abria o menu Vendas, vazio,
para quem não tinha outra permissão de vendas. O que o roteiro apaga não volta —
as linhas de quem a tinha ficam no dump de antes da janela. Rodar de novo não muda
nada. Ensaiado em `ensaiar-sql-do-passo-3.php` (abaixo): só as linhas dela saem, as
outras funcionalidades e as linhas delas ficam, e nada na segunda vez; e o
`VendasCaixaForaDaListaTest` roda os três `DELETE` do roteiro dentro da transação
do teste e prova que o login e a tela nova seguem sem a linha. **Não foi aplicado
em cópia nenhuma** (apagar linha não está na autorização da resposta 83).

**Os privilégios repetidos: as gêmeas juntadas, o molde repetido com o mais antigo, e a chave única no molde e nas pessoas** (**D289**, **D374**; decidido em 12/10/2026, a resposta 125 do dono, a opção (a), *"As duas regras, com os 117 pares juntados"*; e o molde repetido de valores diferentes em 17/10/2026, a resposta 237, a opção (c), *"Fica o mais antigo"*). Aqui, **depois da limpeza** — o bloco 5 dela tira o molde repetido, ficando o mais antigo, e lista o de valores diferentes — e antes do dump:

```bash
mysql franquiabv < scripts/migracao-privilegios-unicos.sql   # mostra antes os moldes repetidos, os pares de pessoas repetidos e as linhas a mais, e a lista do molde repetido que resolve; depois, zero e zero, e as duas chaves
```

**Fica a linha mais antiga de cada par, com o OU delas**: `ler` se alguma lia, `escrever` se alguma escrevia. É o que o `Acl` já somava, linha a linha — ninguém ganha nem perde acesso —, e é a regra que a gravação da tela nova e da janela do ERP já seguia desde 29/09/2026 (o **D289**). Na cópia de 16/08/2026: 117 pares, 235 linhas, 118 a mais; um par só tem valores diferentes, e nenhum molde se repete. **No molde repetido, fica o mais antigo como está** — o menor id, sem o OU —, também quando os dois têm valores diferentes (um "só ler" e um "ler e escrever" no mesmo grupo e funcionalidade): a sua 237 (c), *"como a limpeza fazia até hoje"*, e **a noite não para**. Entre 17 e 18/10/2026 o bloco 5 deixava esse molde, e a chave do molde, que nascia primeiro, parava aqui o `mysql` antes de apagar qualquer linha, para você decidir (a verificação da fatia PRIVILEGIOS-GEMEOS, o **D374**). Agora o bloco 5 o tira e o lista — o grupo, a funcionalidade, o que fica e o que sai —, e esta migração faz o mesmo, pela mesma regra, com o que chegar a ela sem a limpeza antes; as duas chaves nascem depois, e a carga do PostgreSQL, que nasce com elas, não para no molde. **O que muda é para quem entrar no grupo depois do corte** — cadastrado, trocado para ele ou reativado: recebe o molde que ficou, que pode dar menos, ou mais, do que as duas linhas somadas davam (hoje recebe as duas, e o `Acl` as soma). Quem já está no grupo não muda: as linhas dele são as das pessoas. **Para saber antes da janela** quem são, conte na réplica os moldes repetidos de valores diferentes (na cópia de 16/08/2026, zero): `SELECT grupo, funcionalidade FROM privilegios GROUP BY grupo, funcionalidade HAVING COUNT(*) > 1 AND (MIN(ler) <> MAX(ler) OR MIN(escrever) <> MAX(escrever));`. Se der algum, a lista do bloco 5 diz, na noite, qual ficou. **O que ela apaga não volta**: as linhas a mais, das pessoas e do molde, ficam no dump de antes da janela. Rodar de novo não muda nada. **Numa volta ao código antigo**, o "novo" da janela de permissões dele, sem `id`, num par que já tem linha — o caminho de onde as gêmeas vieram — passa a dar erro em vez de gravar a gêmea calado; o código novo responde com a frase. **Na cópia local desde 17/10/2026**, nos dois bancos (no PostgreSQL da cópia, a chave das pessoas com o nome que o gerador dá). Ensaiado em `ensaiar-sql-do-passo-3.php` (abaixo), com o bloco 5 da limpeza antes, como aqui.

**Os cupons de sorteio repetidos: o segundo do mesmo número sai, e a chave única (sorteio, venda, numero)** (**D370**, **D561**; decidido em 12/10/2026, a resposta 124 do dono, a opção (a), com a 218 (a) de 15/10/2026; e a regra trocada pelo dono em 21/10/2026: *"trocar a regra do banco"*). Aqui, depois da limpeza e antes do dump, e **só com o laço do passo 0 já aplicado** (a coluna `numero` é dele):

```bash
mysql franquiabv < scripts/migracao-sorteios-vendas-unicos.sql   # mostra antes os pares (sorteio, venda, numero) repetidos e os cupons a mais, e a lista: o que fica e o que sai; depois, zero, e a chave
```

**Fica o primeiro cupom de cada par** (o menor id, que é o que o sorteio marca como ganhador, o **D72**) -- a menos que o sorteio aponte outro do par como ganhador: então fica esse, e a chave estrangeira não recusa. O que fica leva a marca de ganhador se algum do par a tinha. Na cópia de 16/08/2026: 4 pares, 4 cupons a mais (um no #1, um no #3, dois no #4), de 5 s a 1.419 s um do outro, nenhum deles o ganhador. **Nenhum resultado muda**: o sorteio interno, a planilha e os números já contam cada cupom (venda e número) uma vez; o par só dava chance dupla na planilha de antes. **O que ela apaga não volta**: os 4 cupons ficam no dump de antes da janela. Rodar de novo não muda nada. **Numa volta ao código antigo**, a finalização repetida do PDV antigo, que gravava o segundo cupom calada, passa a dar erro ao finalizar; o código novo dá só os números que faltam, sob a trava do sorteio. **Na cópia local desde 21/10/2026**, nos dois bancos (no PostgreSQL da cópia, a chave com o nome que o gerador dá). Ensaiado em `ensaiar-sql-do-passo-3.php` (abaixo).

**As conclusões repetidas da VIP Class: a primeira de cada par fica, e a chave única (vipclass, usuario)** (**D372**; decidido em 15/10/2026, a resposta 213 do dono, a opção (a): *"Uma conclusão, vale a primeira -- concluir de novo é recusado; as 191 linhas a mais saem, nas duas cópias locais já e na produção na etapa 2, e o banco passa a recusar a segunda"*; a fatia 85, 06/11/2026). Aqui, depois da limpeza e antes do dump -- e não no passo 0: apaga linha (a resposta 192 (a)):

```bash
mysql franquiabv < scripts/migracao-vipclass-conclusao-unica.sql   # mostra antes os pares (curso, pessoa), os repetidos, as linhas a mais e, delas, quantas tem outra nota e outra data, e a lista: o que fica e o que sai; depois, zero repetidos, os pares de antes, uma linha por par, o que saiu igual ao que estava a mais, e a chave
```

**Fica a primeira conclusão de cada par**: a de menor `data` e, se duas têm a mesma `data` (a coluna não tem fração de segundo), a de menor `id` -- a mesma regra com que o cartão do aluno, o "Quem fez" e os dois relatórios antigos já mostram a conclusão desde 05/11/2026. Sai toda conclusão que tem outra mais antiga no mesmo par, e por isso **nenhum par fica sem conclusão**: a última linha do `DEPOIS` diz os pares de antes e os de depois, que têm de ser iguais, e o que saiu, que tem de ser o que estava a mais. Na cópia de 16/08/2026 (contado em 06/11/2026, só `SELECT`): 2.131 conclusões de 1.940 pares; 167 pares com mais de uma, de 119 pessoas em 17 cursos; **191 linhas saem**, todas com a data diferente da primeira, e 4 com a nota diferente (em 3, a que sai tem nota maior que a primeira: a regra é a primeira aprovação, e não a maior nota); nenhum empate de data na primeira. **Ninguém perde a conclusão**: cada pessoa fica com uma em cada curso que concluiu, e o certificado, que não imprime data nem nota, sai igual. **O que ela apaga não volta**: as 191 ficam no dump de antes da janela. Rodar de novo não muda nada. **Numa volta ao código antigo**, o envio repetido do checkpoint antigo, que gravava a segunda conclusão calado, passa a dar erro; o código novo relê a conclusão sob a trava da pessoa e, se a corrida passar por ela, trata a recusa do banco como "Você já concluiu este curso". **Ainda não está nas cópias locais**: a 213 (a) já autoriza a limpeza nelas, e a fatia 85 não a rodou (só `SELECT` na `franquiabv_dev`, pela ordem da fatia); fica com o coordenador, por este roteiro e pelo irmão do PostgreSQL da cópia, `postgres/migracao-vipclass-conclusao-unica.sql` (o D728). Ensaiado em `ensaiar-sql-do-passo-3.php` (abaixo): o par de três, o empate no mesmo segundo (fica o menor id), o de id fora da ordem (fica a menor data), o que não é par, e a segunda vez.

**O ensaio deste passo**, no repositório e sem produção: `php
scripts/ensaiar-sql-do-passo-3.php` monta um banco descartável com as tabelas
do `mysql.sql` de hoje e dados inventados, roda os sete roteiros acima como
este passo os roda (os `UPDATE` tirados dos comentários, como você os
descomenta), confere o resultado linha a linha, e confere também que este passo
ainda manda rodá-los — e que a linha do Líder no passo 0 diz "passo 3". Sai com
1 se algo disso não bater, e com 2 se não conseguir medir. Ele não roda a
limpeza do começo, que tem portão próprio — só o bloco 5 dela, o do molde
repetido, antes da migração dos privilégios repetidos, como aqui. Rode-o com
`CONTEINER_MYSQL=<um 8.0>` também: a réplica é 8.0.46.

### 4. Schema, papéis, dados — nesta ordem

```bash
php scripts/gerar-ddl-postgres.php > postgres/schema.sql
php scripts/gerar-migracao-dados.php > scripts/migrar-dados.sh
psql -v ON_ERROR_STOP=1 -f postgres/schema.sql
psql -tAc "SHOW timezone;"                                   # America/Sao_Paulo
SENHA_APP='…' sh scripts/criar-papeis.sh
sh scripts/migrar-dados.sh                                   # cronometre
```

**Os dois geradores, e não só o do schema** (desde 03/10/2026, **D379**). O
roteiro de carga também é gerado do `mysql.sql` — as listas de colunas de cada
`SELECT` e de cada `\copy` —, e o passo 1 acabou de refazer o `mysql.sql`. O
`migrar-dados.sh` do repositório ficou de 27/08 a 03/10 sem ser regerado, e não
copiava `clientes_sessoes`, `usuarios_horarios`, `vendas.autorizado_por`,
`vendas.autorizado_em`, `pontuacoes_historicos.autorizado_por`,
`unidades.pontuacao_validade_dias` nem as três `hash_anterior*`: o PostgreSQL as
receberia vazias ou com o padrão, sem erro nenhum. Em produção elas estão vazias
ou no padrão até o corte — o código antigo não as escreve —, e por isso nada se
perderia desta vez; da próxima mudança, sim.

**Papéis depois do schema, sempre.** Recarregar o schema faz `DROP SCHEMA bvip
CASCADE` e leva junto todas as permissões. Invertido, a aplicação sobe
normalmente e falha na primeira consulta.

### 5. Portão 1 — a carga

```bash
php scripts/conferir-migracao.php
```

**Seis** provas independentes: contagem por tabela, soma de toda coluna numérica
(em decimal exato, comparada como texto), órfãos de chave estrangeira,
sequências à frente do maior id, extremos de cada coluna de data como texto, e —
desde 31/08/2026 — **a forma das colunas**.

As cinco primeiras conferem o **dado**. A sexta confere o **recipiente**:
existência e nulabilidade de cada coluna nos dois bancos. Ela entrou porque as
cinco tinham um buraco de forma — uma coluna com nulabilidade diferente
atravessa todas: a carga passa, as contagens batem, as somas batem, e o defeito
só aparece meses depois, quando alguém grava um nulo onde um dos lados não
aceita.

Na medição de 31/08 os dois bancos concordam em **890 colunas**, com uma única
ausência — `usuarios.senha_php83`, removida do schema novo de propósito
(**D7**) —, que está registrada como exceção nomeada. As 80 colunas a mais no
PostgreSQL são 75 `inquilino_id` e as 5 da tabela `inquilinos`.

**Na simulação de 03/10/2026** (**D323**, refeita na revisão do mesmo dia) — a
estrutura do dump de produção com o laço do passo 0 aplicado, num MySQL
descartável, e o schema gerado dela, num PostgreSQL descartável, os dois vazios
—, a prova passa com **941 colunas** comuns, a mesma ausência, e 86 a mais no
PostgreSQL: 76 `inquilino_id`, as 5 de `inquilinos` e as 5 de `sessoes`.

**A `sessoes` é a segunda exceção nomeada**, desde a revisão: a sessão do ERP no
banco (F1.5) que o gerador cria à mão no PostgreSQL, e que o MySQL de produção
não tem — nenhum ambiente liga a sessão no banco (D198). Até ali a prova a
acusava "só no PostgreSQL", com as cinco colunas, e a primeira saída achada foi
pôr `sql/sessoes.mysql.sql` no passo 0. **Essa saída parava a janela aqui
atrás, no passo 4**: com a tabela em produção, o passo 1 a levaria ao
`mysql.sql`, e o gerador a emitia duas vezes — medido, o `psql` parou em
`relation "sessoes" already exists`. Agora a prova aceita a `sessoes` ausente
no MySQL e, se um dia ela estiver nos dois, a confere como as outras (com ela
nos dois: 946 comuns, e uma nulabilidade mudada só no MySQL reprova); e o
gerador não emite a `sessoes` do dump, só a dele.

**Sai com 1 se qualquer uma falhar. Falhou, não segue.**

#### 5b. A vigência do horário: a semente, depois da carga

`migrar-dados.sh` **não precisa** de `usuarios_horarios`: ela nasce vazia, e a
semente a preenche aqui. Desde 03/10/2026 o roteiro de carga é regerado no
passo 4 e a traz (**D379**): em produção ela está vazia, o `\copy` carrega
`COPY 0` e passa no `conferir()` — inofensivo.

```bash
psql -v ON_ERROR_STOP=1 -f postgres/semente-usuarios-horarios.sql   # termina com sem_vigencia = 0
```

A semente dá a cada usuário uma linha `semente` com o horário de hoje, valendo
desde a última edição dele (`COALESCE(data_alteracao, data_criacao)`): antes
dessa data, a ocupação daquele colaborador sai pela régua antiga do Dash
(decisão B6 do dono). Pode rodar de novo sem duplicar.

**O conferidor NÃO roda aqui**, e rodava até a revisão de 02/10/2026: ele lê o
banco do `local.php`, que só passa para o PostgreSQL no passo 7. Rodado neste
ponto, media o MySQL de produção — com a tabela vazia, porque ninguém a semeou
lá —, saía com 1 e sugeria rodar a semente NELE. Ele e o teste rápido ficam no
**7b**, depois de apontar a aplicação.

`dashboard_colaboradores` **continua sendo migrada**: é a régua antiga dos meses
sem vigência, e a F5 não a apaga (decisão B6).

### 6. Portão 2 — os papéis

```bash
SENHA_APP='…' sh scripts/conferir-papeis.sh
```

Seis provas que **tentam violar** as propriedades em vez de lê-las: o
isolamento por inquilino com contraprova, e a demonstração de que o dono
superusuário enxerga o que `bvip_app` não enxerga.

**Eram dezesseis até 09/09/2026.** As dez que saíram eram todas do BI — cinco
tentativas de escrita, duas de leitura, três de isolamento —, e saíram com ele
(**D9**). A montagem do segundo inquilino ficou: ela não era do BI, é o que dá
o que observar às provas de RLS do `bvip_app`.

### 7. Apontar a aplicação

```bash
cp config/autoload/local.php config/autoload/local.mysql.php.guardado
SENHA_APP='…' php scripts/trocar-doctrine-do-local-php.php \
    config/autoload/local.mysql.php.guardado config/autoload/local.php \
    --host=<o host do PostgreSQL> --banco=<o banco do passo 4>   # [--porta=5432]; sai 0, ou recusa sem escrever
rm -rf data/DoctrineORMModule/Proxy/*
php scripts/verificar-local-php.php                                # SAÍDA 1 PARA O CORTE AQUI, antes do passo 8
```

**A PRIMEIRA LINHA NÃO ESTAVA AQUI ATÉ 30/08/2026, e sem ela a volta atrás
morre neste passo.** O `local.php` não é versionado: é o **único lugar onde os
dados da conexão MySQL existem**. A segunda o reescreve.

**SÓ O BLOCO `doctrine` MUDA** (desde 08/11/2026, a fatia 128; o **D329**). Até então a segunda linha era
`cp config/autoload/local.postgres.php.dist config/autoload/local.php` -- e o dist é o modelo de
**desenvolvimento**: ligava o rastro de pilha que o **D16** tirou de produção, mandava o e-mail ao Mailpit, e não
trazia OneSignal, Zoomtec, SMS, Yungas, Asaas nem `cron.token`. O `scripts/trocar-doctrine-do-local-php.php` parte do
`local.mysql.php.guardado` e troca só o bloco `doctrine`: o driver do PostgreSQL, o host, a porta, o banco,
`bvip_app` e a senha (a mesma `SENHA_APP` do `criar-papeis.sh` do passo 4; nunca na linha de comando, e o roteiro não
a imprime). O resto do arquivo sai igual, byte a byte, e ele confere isso **avaliando** os dois arquivos antes de
escrever. Ele **recusa sem escrever nada** (saída 1, com o motivo) quando o guardado não é um `local.php` do MySQL,
quando o bloco `doctrine` aparece mais de uma vez, quando o destino é o próprio guardado, e quando o bloco `doctrine`
do guardado tem alguma chave além da conexão `orm_default` (`params`, `driverClass`, `driverOptions`) -- o bloco novo
não a levaria, e ela some calada: a recusa diz o nome, e quem opera a leva à mão. O destino é escrito no lugar, como o
`cp` fazia (o dono e a permissão do `local.php` ficam). Ensaiado sobre um `local.php` inventado
(`scripts/ensaiar-troca-do-doctrine.sh`, no `conferir-tudo.sh` e no CI); o de produção nunca foi lido daqui.

**O `verificar-local-php.php` vem antes do passo 8, e a saída 1 para o corte** (o **D329**). Ele confere as chaves
fatais, a permissão do arquivo para o php-fpm, as chaves que não deveriam estar (o rastro de pilha), e **conecta no
banco do driver declarado** -- no PostgreSQL, com o `bvip_app` e a senha do arquivo, e conta as tabelas do esquema
(desde 08/11/2026; até então ele conectava sempre pelo MySQL, e sobre este `local.php` sairia 1 com as credenciais
certas). Saiu 1: volte o `local.mysql.php.guardado` (a volta atrás, mais abaixo) ou corrija e rode de novo; o 8 não
começa com ele vermelho. **Sem o `pdo_pgsql` no PHP da linha de comando ele sai 1** -- "CONEXÃO NÃO PROVADA", com o
nome da extensão (desde a correção da fatia 128; até então pulava a conexão calado e dizia "Deploy liberado"): o
`pdo_pgsql` é um item à parte da etapa 2, e o ini da linha de comando pode ser outro que o do php-fpm. Instale-o (o
pacote `php<versão>-pgsql`) e rode de novo; **nunca** com o `--sem-conexao`, que é a conferência do arquivo fora do
servidor e termina dizendo "A CONEXÃO NÃO FOI CONFERIDA", e não "Deploy liberado".

O plano de contingência, mais abaixo, manda restaurar
`local.mysql.php.guardado` — arquivo que só era criado em
`docs/ambiente-homologacao.md`, outro documento. Quem seguisse este
procedimento sozinho destruiria a conexão MySQL no passo 7 e descobriria no
momento de voltar, que é o pior momento possível para descobrir.

O `local.php` conecta como **`bvip_app`**, não como o dono. É essa diferença que
faz o RLS existir — o dono é superusuário e ignora as 76 políticas (**D48**;
eram 75 até a `usuarios_horarios`, contadas em `postgres/schema.sql` em
03/10/2026).

O diretório de proxies é gerado e está fora do versionamento; deixá-lo com os
proxies do MySQL dá HTTP 500 na primeira página. Já aconteceu, em 24/08.

**AS TRÊS CHAVES DO SEGREDOS JÁ VÊM DO GUARDADO** (desde 08/11/2026, o **D329**). Com o `cp` do dist, o `local.php`
novo nascia sem a chave do Asaas e sem o token das rotinas (desde 03/10/2026, SEGREDOS: o dist os traz vazios de
propósito, e até aquele dia o código os trazia escritos), e este passo mandava levá-los à mão. A troca só do bloco
`doctrine` os mantém. O que ainda pede atenção, com a mesma lista:

- **`asaas.api_key`** -- a chave **rotacionada** pelo D3 (passo 5 do dia 0),
  nunca a antiga. Sem ela, `relatoriosRoyaltiesFaturasGerar` e
  `relatoriosRoyaltiesFaturasAtualizar` lançam exceção antes de falar com o
  Asaas, e nenhuma fatura de royalties sai nem é atualizada.
- **`cron.token`** -- **um valor novo: a rotação da linha 9 do passo 5 do dia 0
  é aqui, se ainda não rodou** (decisão de 06/10/2026, D331: o crontab muda de
  forma no passo 9, e muda junto com a rotação). O passo 9 refaz o crontab
  **a partir deste valor**, e por isso ele não precisa ser o que o crontab de
  hoje manda. Para sortear e gravar sem imprimir:
  `sh scripts/sortear-token-cron.sh config/autoload/local.php` (48
  hexadecimais, no fim do array, que vencem o `'cron'` que veio do guardado). Se a
  linha 9 já rodou antes da janela, o valor dela já veio do `local.mysql.php.guardado`.
  **Nunca o antigo**: ele está no repositório, no histórico do
  `CronJobController` e escrito no `VisagismoController`. Sem `cron.token`,
  as 26 rotinas recusam todo pedido **com 401** (desde 03/10/2026, D4), e o
  `data/logs/log_scripts.txt` para de receber linhas. A volta atrás antes do
  passo 9 não sente a troca: o `local.php` guardado e o crontab de antes têm
  o mesmo valor, os dois.
- **`whatsapp.central`** -- o provedor, a instância e a chave do WhatsApp
  central da franqueadora, por onde sai o código de entrada do aplicativo novo
  (o login V4; o **D59**, as respostas 147 e 169 do dono). **O número é um novo,
  da franqueadora, só para os códigos, numa instância nova no provedor** (a 169,
  a opção (a)): o Evolution ou o WAHA, os mesmos das unidades. Se a instância
  já estiver conectada e no `local.mysql.php.guardado`, os três valores já vêm de
  lá; se ainda não, o dono manda criá-la e conectar o aparelho, e eles entram
  aqui depois -- os dists trazem o bloco vazio e o passo (`local.php.dist` e
  `local.postgres.php.dist`). Vazio, o login V4 não manda código a ninguém, e
  o aplicativo instalado não sente: ele não usa o login V4. Nada de valor no
  repositório nem neste documento.

No `verificar-local-php.php` da última linha do bloco, as três aparecem como
`grave` se faltarem (do central, a instância e a chave), e `grave` não
reprova -- leia a lista.

#### 7b. A vigência do horário, medida pela aplicação

Só agora o `local.php` aponta para o PostgreSQL, e os dois roteiros abaixo leem
o banco do `local.php` — host e nome estão escritos nele, sem variável de
ambiente. Por isso cada um recebe a plataforma: rodado no lugar errado, sai com
**2** e diz para onde o `local.php` aponta, em vez de medir o banco errado.

```bash
docker compose exec -T app php scripts/conferir-vigencia-do-horario.php --plataforma=postgresql   # tem de sair com 0
docker compose exec -T app php scripts/ensaiar-vigencia-do-horario.php --plataforma=postgresql    # tem de sair com 0
```

- O **conferidor** confere que todo usuário tem a vigência e que a última é o
  cadastro de hoje.
- O **ensaio** é o teste rápido, sem mudar ninguém: passa pela porta `estado`
  do `UsuarioService` com a conexão da aplicação (`bvip_app`, sob o RLS),
  confere **uma** linha nova com o autor, e desfaz a transação — depois conta de
  novo, de fora, e confere que nada ficou. A versão anterior deste passo mandava
  "salvar o horário de um usuário de teste e conferir uma linha `agenda`":
  produção não tem usuário de teste, e salvar sem mudar não grava linha (a
  vigência só grava quando o retrato muda). Para ver a linha, alguém mudaria de
  verdade a grade de um colaborador real, e a mudança entraria no histórico da
  ocupação dele.

**Falhou, não segue.** Os dois rodam também no ensaio geral
(`scripts/ensaiar-corte.sh`, as fases `semente` e `vigencia`).

#### 7c. O acento da busca de cliente: a passada, e SÓ DEPOIS a chave

No PostgreSQL a busca de cliente compara byte a byte, e quem tem acento no nome
só é achado por quem digita o acento certo: 20.716 clientes, 8,5% da cópia
(**D119**). A passada tira o acento da coluna `clientes.tags`; a chave faz o
termo e as gravações seguintes perderem o acento também.

```bash
docker compose exec -T app php scripts/normalizar-tags.php             # só mede; na cópia, 20.672 a normalizar
docker compose exec -T app php scripts/normalizar-tags.php --aplicar   # escreve, em lotes
docker compose exec -T app php scripts/normalizar-tags.php             # tem de dizer: a normalizar 0
# SÓ AGORA, em config/autoload/local.php:   'busca' => ['tags_sem_acento' => true, 'campanhas_sem_acento' => true],
```

**A segunda chave é a das campanhas (D618, 07/11/2026):** a busca das campanhas por título e por texto, na lista
antiga e na tela nova, sem o acento nos dois lados (a coluna pelo `TRANSLATE` do mesmo mapa, o termo pelo `semAcento()`).
Ela não depende da passada -- não há coluna a reconstruir -- e entra na mesma linha só para ligar junto: sem ela, no
PostgreSQL, quem procura "promocao" não acha "Promoção".

**Nessa ordem.** Com a chave ligada antes da passada, o termo vai sem acento a
uma coluna que ainda o tem, e a busca para de achar quem ela mesma gravou. Ela
lê o banco do `local.php` — por isso vem depois do 7, e escreve só no
PostgreSQL: a volta atrás não precisa desfazer nada, e o `local.php` guardado
do MySQL não tem a chave. Se o PHP de produção tiver
`opcache.validate_timestamps` desligado, a chave só vale depois de recarregar o
PHP-FPM. Para com **2** se a tabela vier vazia (o RLS sem inquilino) — não é
"nada a fazer".

**É decisão sua fazê-lo nesta janela** (D119: "decisão de quem opera"); pular as
duas linhas é possível, e a busca no PostgreSQL fica perdendo quem tem acento.
O tempo da passada **não foi medido** em produção nem no ensaio geral — o
roteiro do ensaio não a tem: meça-a à parte e some à janela.

#### 7d. Desde quando o cancelamento, o desconto manual, o valor contado, o motivo da retirada, a cota do pacote, o custo do item e a adesão pelo formulário são gravados: as linhas, com o código novo apontado

**Depois do 7 e antes do 9, com a aplicação nova apontada e ninguém usando**
(a fatia 70 da fila das telas que faltam, PDV-CANCELAMENTO; a nota 4 dela e a
1.5 de `docs/f3-relatorios/spec-ordem.md`):

```bash
psql -v ON_ERROR_STOP=1 -f postgres/colunas-gravadas-cancelamento.sql   # termina com a linha `cancelamento`, e só ela
psql -v ON_ERROR_STOP=1 -f postgres/colunas-gravadas-desconto-manual.sql   # termina com `cancelamento` e `descontoManual`
psql -v ON_ERROR_STOP=1 -f postgres/colunas-gravadas-valor-contado.sql   # termina com `cancelamento`, `descontoManual` e `valorContado`
psql -v ON_ERROR_STOP=1 -f postgres/colunas-gravadas-motivo-da-retirada.sql   # e `motivoDaRetirada`: as quatro
psql -v ON_ERROR_STOP=1 -f postgres/colunas-gravadas-cota-do-pacote.sql   # e `cotaDoPacote`: as cinco
psql -v ON_ERROR_STOP=1 -f postgres/colunas-gravadas-custo-do-item.sql   # e `custoDoItem`: as seis
psql -v ON_ERROR_STOP=1 -f postgres/colunas-gravadas-indicacao-do-formulario.sql   # e `indicacaoDoFormulario`: as sete
psql -v ON_ERROR_STOP=1 -f postgres/encerrar-pacotes-de-venda-cancelada.sql | tee /root/encerrar-pacotes-pg-<data>.txt   # D688
psql -v ON_ERROR_STOP=1 -f postgres/recalcular-pontuacao-no-corte.sql | tee /root/recalcular-pontuacao-pg-<data>.txt   # D284, D312
```

A linha `cancelamento` de `colunas_gravadas` diz aos relatórios "o PDV grava
quando e quem cancelou desde este instante", e o número que depende dela é nulo
antes. **Ela só diz a verdade com o código que grava no ar**, e por isso não está
no passo 0: a `migracao-venda-cancelamento.sql` cria as colunas lá (antes do
passo 1, como toda coluna), mas o código antigo de produção nunca grava o
instante, e a linha escrita no passo 0 diria "gravado desde" o dia do passo 0 com
toda venda cancelada até a janela sem o registro -- a quebra nula que parece
registro, o defeito que a 1.5 existe para impedir. Aqui, com a aplicação nova
apontada e antes de liberar, nenhum cancelamento acontece entre o código e a
linha. **Com a etapa 1 feita** (`procedimento-de-corte-etapa-1.md`), a linha já
existe no MySQL: o passo 7 de lá a escreve logo depois da troca da árvore, e a carga
do passo 4 daqui (`migrar-dados.sh`) a copia com a `colunas_gravadas`. Então este
passo não muda nada -- só escreve se não houver --, e a data continua a da etapa 1:
os cancelamentos gravados entre as duas etapas continuam contados. **Só no corte de
uma vez só** a `colunas_gravadas` chega vazia (nenhuma migração escreve linha nela) e
a linha nasce aqui. Idempotente: rodada duas vezes, a segunda não muda o instante. **A segunda linha é a do
desconto manual** (`descontoManual`, a fatia 71, PDV-DESCONTO-LANCADO-POR): o código
novo grava quem lançou o desconto digitado no PDV, e a linha vai logo depois da do
cancelamento, pela mesma regra e com o mesmo arquivo irmão
(`postgres/colunas-gravadas-desconto-manual.sql`); com a etapa 1 feita, ela também já
veio do MySQL, e este passo não a muda. **A terceira é a do valor contado**
(`valorContado`, a fatia 72, PDV-GAVETA-CONTADA): o código novo faz o fechamento do
caixa pedir e gravar o dinheiro contado na gaveta, e a linha vai logo depois da do
desconto manual -- sempre depois da do cancelamento, que a quebra pede (a gaveta ao
fechar devolve as canceladas depois do fechamento) --, com o arquivo irmão
(`postgres/colunas-gravadas-valor-contado.sql`); com a etapa 1 feita, ela também já
veio do MySQL. **A quarta é a do motivo da retirada** (`motivoDaRetirada`, a fatia
73, PDV-MOTIVO-DA-RETIRADA): o código novo faz a saída pela tela de estoque pedir o
motivo (perda, vencimento, acerto de inventário) e a retirada de uso interno gravar
`uso`, e a linha vai logo depois da do valor contado, com o arquivo irmão
(`postgres/colunas-gravadas-motivo-da-retirada.sql`); com a etapa 1 feita, ela também
já veio do MySQL. **A quinta e última é a da cota do pacote** (`cotaDoPacote`, a fatia
74, PDV-COTA-DO-PACOTE): o código novo faz o cadastro do pacote pedir o percentual da
cota-parte de quem vendeu e a finalização de toda sessão de pacote gravar quem vendeu e
a cota dele, e a linha vai logo depois da do motivo da retirada, com o arquivo irmão
(`postgres/colunas-gravadas-cota-do-pacote.sql`); com a etapa 1 feita, ela também já
veio do MySQL. **A sexta é a do custo do item** (`custoDoItem`, a fatia 74b,
PDV-CUSTO-DO-ITEM; a resposta 224 (b) do dono): o código novo grava em cada item de
produto vendido o custo do cadastro naquele momento (`vendas_produtos.custo_unitario`), e
a linha vai logo depois da da cota do pacote, com o arquivo irmão
(`postgres/colunas-gravadas-custo-do-item.sql`); com a etapa 1 feita, ela também já veio
do MySQL. **A sétima é a da indicação do formulário** (`indicacaoDoFormulario`, a
fatia 75, REL-INDICACAO-PORTA; as respostas 264 (b) e 265 (b) do dono): o código novo
grava a linha do convite do formulário também para quem indica inativo, e a etapa 3 do
amigo sem a ligação marca a adesão nela, e a linha vai logo depois da do custo do item,
com o arquivo irmão (`postgres/colunas-gravadas-indicacao-do-formulario.sql`); com a
etapa 1 feita, ela também já veio do MySQL. Numa entrega
depois da janela, a linha vai depois da troca da árvore e antes de liberar, pela
mesma regra.

**Logo depois das sete, os pacotes de venda já cancelada** (a fatia 74g; a
resposta 287 (a) do dono, o **D688**): `postgres/encerrar-pacotes-de-venda-cancelada.sql`
encerra uma vez, com a marca do cancelamento novo (`status` falso e
`data_alteracao`; nada apagado), os pacotes ainda ativos de venda já cancelada,
com a contagem e a lista impressas antes do `UPDATE` (leia e anote; não para com
pacote ainda válido: o número vai ao dono) e a conferência no fim (0). **Com a
etapa 1 feita**, o passo 7 de lá já os encerrou no MySQL e a carga os trouxe: este
conta 0 e não muda nada. **Só no corte de uma vez só** ele encerra aqui, com o código
da 74f já apontado -- antes dele, a venda cancelada no meio ficaria com o pacote
ativo. Idempotente; o caminho de volta é a lista guardada pelo `tee` (o cabeçalho
do roteiro).

**Logo depois dos pacotes, a pontuação de todos pelo histórico** (a fatia 106; as
respostas 230 (a) e 123 (a) do dono, nessa ordem; o **D312** e o **D284**):
`postgres/recalcular-pontuacao-no-corte.sql` cria a linha de saldo zerada
(inquilino, cliente, unidade) de cada par com pontos `ativa` e sem saldo, e depois
grava em toda linha de saldo que difere a soma das linhas `ativa` -- a conta de
`ClienteService::atualizarPontuacao` --, com a contagem antes (os pares sem saldo;
das linhas, as que diferem, acima, abaixo, nulas, e o líquido), as listas do que
nasce e do que muda, com o número de antes, e a conferência no fim (a mesma contagem de antes: as linhas de saldo, e todo o resto zero). Só a
coluna `pontuacao` e as linhas novas; o crédito em dinheiro fica. Ele começa por
`ANALYZE` das duas tabelas: logo depois da carga, sem as estatísticas, a contagem
levou 61 s num clone descartável (menos de 2 s com elas). **Com a etapa 1 feita**,
o passo 7 de lá já recalculou o MySQL e a carga trouxe o número: este conta e
acerta só o que o intervalo entre as duas etapas deixou. **Só no corte de uma vez
só** ele recalcula todos aqui. Leia a contagem e anote-a; não é ponto de parada.
Idempotente; o caminho de volta é a lista guardada pelo `tee` (o cabeçalho do
irmão do MySQL, `scripts/recalcular-pontuacao-no-corte.sql`).

### 8. Portão 3 — o comportamento, e a suíte que NÃO roda aqui

**NUNCA CARREGUE AS FIXTURES NA PRODUÇÃO.** Até 03/10/2026 este passo mandava
rodar `phpunit --testsuite caracterizacao` aqui, contra o banco que acabou de
virar produção (**D378**). A suíte não passa nele, e a mensagem com que ela
para manda escrever nele: sem a unidade 90001000,
`CaracterizacaoTestCase::exigirFixtures` diz *"Aplique antes de rodar a
suíte"* e imprime o comando que carrega `tests/Caracterizacao/fixtures.sql` —
95 `INSERT` em 44 tabelas, com unidades, usuários, clientes, vendas e caixas
inventados. O comando impresso é o do MySQL: seguido na janela, grava as
fixtures no MySQL de produção, que é a base da volta atrás; adaptado ao
PostgreSQL, grava no banco que vai ao ar. E com as fixtures carregadas ela
manda usar `rodar-caracterizacao-como-ci.sh` ou `rodar-caracterizacao-postgres.sh`,
que montam bancos de teste e trocam o `local.php` da aplicação — aqui, o de
produção.

**A suíte sai da janela: decidido em 06/10/2026** (D378; lote de perguntas,
pergunta 7, a opção (a)). No lugar dela ficam o ensaio geral, as provas da
carga (5) e dos papéis (6), o 7b e o teste manual. **Este passo não roda nada
contra o banco de produção**, e o portão de comportamento da janela é o teste
manual abaixo. A suíte tem hoje **1.611 casos em 161 arquivos** (`phpunit
--list-tests`, 03/10/2026; eram 109 quando este passo foi escrito) e é provada
antes da janela, nos dois bancos, pelos roteiros que montam bancos próprios com
as fixtures. O clone do PostgreSQL carregado, com as fixtures só nele, fica
para o **ensaio geral**, sobre a cópia — e está escrito desde 11/10/2026 (D378:
as fases `clone_pg` e `clone_mysql` do `ensaiar-corte.sh`, ver
`ensaio-geral-do-corte.md`).

**A suíte de contrato NÃO roda neste passo, e isto mudou em 30/08/2026.** Ela é
recusada de propósito: `ContratoTestCase` verifica em que banco está antes de
começar, e aborta se encontrar unidades reais. Os snapshots congelam as
respostas do cenário determinístico das fixtures; apontados para dados de
produção eles falham em cascata por serem outra base, e não por regressão de
contrato. Duas vezes isso custou caro antes de a guarda existir.

O lugar da suíte de contrato é o CI, contra o banco de fixtures:

```bash
sh scripts/montar-banco-contrato.sh
sh scripts/rodar-contrato.sh                                 # 67 testes em 03/10/2026 (eram 35)
```

A referência antiga a "**4 falhas conhecidas**" está obsoleta: as quatro eram
snapshots que congelavam respostas vazias, e foram corrigidas ao carregar as
fixtures que faltavam. Se alguém as reencontrar, é sinal de banco de contrato
mal montado -- não de falha esperada.

Depois, à mão, o que os testes não cobrem:

- entrar no sistema — e entrar de novo **digitando o login com a primeira letra
  maiúscula**, como o teclado do celular faz (**D349**: no PostgreSQL, sem o
  código do D349, não entra — por isso ele é o 6 de "O que hoje bloqueia", e a
  janela não começa sem ele)
- buscar um cliente que tem acento no nome digitando **sem** o acento (só acha
  se o 7c rodou; se você o pulou, este item não vale)
- abrir uma agenda do dia
- lançar uma venda com desconto e fechá-la
- abrir o relatório de comissões e o dashboard
- um endpoint do aplicativo móvel
- **conferir uma data na tela** contra o que o MySQL mostrava

**Qualquer falha aqui é motivo de volta atrás, não de análise no meio da
janela** — a regra que valia para a suíte, e que vale para o que ficou no lugar
dela. Até a revisão de 03/10/2026 ela tinha saído junto com a suíte, e o teste
manual ficou sem regra de falha. A única exceção é a busca sem acento com o 7c
pulado, que falha por construção e você já sabe.

### 9. Religar o cron e liberar

**O crontab volta em outra forma: decidido em 06/10/2026** (D331; lote de
perguntas, pergunta 5, a opção (b)), **e sem e-mail desde 12/10/2026** (a
resposta 128 (b), *"Sem e-mail: o aviso vai ao log do sistema"*, que mudou a
31). Cada linha passa de `curl -s "<url>?token=<token>" > /dev/null 2>&1` a
`curl -fsS -m <limite> "<url>" -H "Authorization: Bearer <token>" > /dev/null
|| logger -t rotinas "falhou (curl $?): <a rota>"`. O token sai da URL — e do
log de acesso do nginx —, o `-f` faz o `curl` sair com código diferente de zero
no 401 e no 500 (o 22; o 28 é o tempo do `-m` esgotado, o 7 a conexão
recusada), e esse código vai ao log do sistema pelo `logger`, com a rota. O
`-m` de cada linha fica o que ela já tem (a tabela de `docs/f1-6-agendamento.md`),
e o domínio vira o raiz, `franquiabv.com.br` (a resposta 194 (a); o crontab de
hoje mistura `www` e o raiz, sem `-L`). **A da apuração dos royalties** (a das
05:25) guarda a resposta e manda ao log também o `aviso` dela — o retrato de
ontem que somou zero com unidade ativa (o **D364**): `R=$(curl ...) || logger
...; case $R in *aviso*) logger -t rotinas "<a rota>: $R" ;; esac`. O crontab
começa por `MAILTO=""`: sem endereço, o cron não manda a saída a lugar nenhum,
e o aviso é o do `logger`. O token continua escrito no crontab, como hoje, e
agora é o **novo** (a rotação do passo 7). São dois blocos, e **nenhum imprime
o token**. O primeiro faz a troca a partir do crontab guardado no passo 1:

```bash
# o codigo novo esta no ar: sem token, a rotina recusa -- e nao roda
curl -s -o /dev/null -w "%{http_code}\n" https://franquiabv.com.br/api/cronjobs/npsContabilizar   # tem de dar 401
# o crontab novo (D331): o token do local.php do passo 7, no cabecalho -- sem PHP no host: docker compose exec -T app php -r '...'
CRON_TOKEN=$(php -r '$c = require "config/autoload/local.php"; echo $c["cron"]["token"] ?? "";')
export CRON_TOKEN
echo "cron.token lido: ${#CRON_TOKEN} caracteres"                                                 # 0 aqui: o bloco de baixo nao instala
umask 077
# sem e-mail (a resposta 128 (b)): o MAILTO vazio, e o aviso de cada linha vai ao log do sistema pelo logger
awk 'NR == 1 { print "MAILTO=\"\"" }
     /^MAILTO=/ { next }
     /\/api\/cronjobs\/(relatoriosDashboard(Colaboradores|FluxoMovimento)|relatoriosFidelizacao|relatoriosEvasao)Atualizar[?"]/ { next }   # as quatro rotinas que sairam do codigo (13/10, 25/10 e 04/11/2026) nao voltam
     /curl / && /token=/ {
         sub(/ -s /, " -fsS ")
         sub(/ 2>&1/, "")
         sub(/https:\/\/www\.franquiabv\.com\.br\//, "https://franquiabv.com.br/")
         if (match($0, /\?token=[^"]*"/))
             $0 = substr($0, 1, RSTART - 1) "\" -H \"Authorization: Bearer " ENVIRON["CRON_TOKEN"] "\"" substr($0, RSTART + RLENGTH)
         rota = "?"
         if (match($0, /\/api\/[A-Za-z0-9\/]+/))
             rota = substr($0, RSTART, RLENGTH)
         if (rota == "/api/cronjobs/relatoriosRoyaltiesAtualizar" && sub(/ curl /, " R=$(curl ") && sub(/ *> *\/dev\/null *$/, ")"))
             $0 = $0 " || logger -t rotinas \"falhou (curl $?): " rota "\"; case $R in *aviso*) logger -t rotinas \"" rota ": $R\" ;; esac"
         else
             $0 = $0 " || logger -t rotinas \"falhou (curl $?): " rota "\""
     }
     { print }' /root/crontab-antes-do-corte.txt > /root/crontab-depois-do-corte.txt
unset CRON_TOKEN
```

O segundo confere e **só instala se a conferência bater** — e é o que se roda
de novo depois de corrigir uma linha à mão:

```bash
# a conferencia, so contagens -- o primeiro numero e o das linhas de curl do crontab guardado menos as quatro que sairam (21 pela conta de hoje)
if awk '!/^[ \t]*#/ && /curl / { c++; if (/Authorization: Bearer [^" ]/) b++; if (/ -fsS /) f++; if (/ -m [0-9]+ /) m++; if (/2>&1|2> *\/dev\/null|&>/) e++
            if (/ \|\| logger -t rotinas "falhou \(curl \$\?\): \/api\/[A-Za-z0-9\/]+"/) l++; if (/"https:\/\/franquiabv\.com\.br\/api\//) d++; if (/%/) p++
            if (/\/api\/cronjobs\/relatoriosRoyaltiesAtualizar"/) { r++; if (/; case \$R in \*aviso\*\) logger -t rotinas /) a++ } }
        /token=/ { t++ }
        /^MAILTO=/ && !/^MAILTO=""$/ { mt++ }
        END { printf "%d curl, %d Bearer, %d -fsS, %d -m, %d token=, %d descartando o erro, %d com o aviso no log, %d no dominio raiz, %d com %%, %d MAILTO com endereco, %d da apuracao dos royalties (%d com o aviso dela)\n", c, b, f, m, t, e, l, d, p, mt, r, a
              exit !(c > 0 && b == c && f == c && m == c && t == 0 && e == 0 && l == c && d == c && p == 0 && mt == 0 && r == 1 && a == 1) }' /root/crontab-depois-do-corte.txt
then
    wc -l /var/log/nginx/*.log > /root/nginx-antes-do-cron.txt   # a marca: quantas linhas cada log do nginx tem AGORA (so contagens)
    crontab /root/crontab-depois-do-corte.txt && echo "crontab INSTALADO"
else
    echo "NAO INSTALADO: a conferencia nao bateu -- corrija /root/crontab-depois-do-corte.txt e rode este bloco de novo"
fi
```

**O `crontab` só roda com a conferência batendo** (revisão de 06/10/2026: até
ali ela só imprimia as contagens, e o `crontab` vinha na linha seguinte — quem
colasse o bloco inteiro instalava `Authorization: Bearer ` vazio nas 25 linhas).
Bater é: todo `curl` com `Bearer`, `-fsS` e `-m`, com o `|| logger -t rotinas
"falhou (curl $?): <a rota>"` e no domínio raiz; nenhum `token=`, nenhum erro
descartado, nenhum `%` numa linha de `curl` (o cron troca o `%` por fim de
linha) e nenhum `MAILTO` com endereço (a 128 (b)); e **uma** linha da apuração
dos royalties, com o `case $R in *aviso*)` que leva o aviso dela ao log (o
**D364**). Até 07/11/2026 a conferência exigia o contrário — um `MAILTO` com
endereço —, e o endereço nunca veio: com o bloco de antes, a janela parava
aqui. O primeiro
número tem de ser o das linhas de `curl` do crontab guardado menos as quatro
rotinas que saíram do código — 21 pela conta de hoje (25 lidas em 27/08/2026; a das
23:30, `relatoriosDashboardColaboradoresAtualizar`, e a das 07:00 do dia 1,
`relatoriosDashboardFluxoMovimentoAtualizar`, saíram em 13/10/2026 com as telas que
liam as tabelas delas; a das 03:10, `relatoriosFidelizacaoAtualizar`, saiu em
25/10/2026 com o item escondido do menu, a resposta 138 (a) do dono; a da Evasão das
04:10, `relatoriosEvasaoAtualizar`, saiu em 04/11/2026 com a tela antiga dela, pela
mesma resposta -- a CA3 da Carteira de clientes, `docs/f3-relatorios/spec-carteira.md`. O primeiro bloco
as tira do crontab guardado da produção: sem isso, voltariam e dariam 404 todo dia,
com uma linha `falhou` no log do sistema a cada vez. A das 23:50, `fidelizacaoAtualizar`, é outra rotina e fica; e a do
ticket médio, `unidadesTicketMedioAtualizar`, roda nas mesmas 04:10 da Evasão, é outra
rotina e fica: o filtro tira a linha pelo nome da rotina, e não pelo horário). `NAO INSTALADO` com `Bearer`, `-fsS` ou `-m` abaixo de `curl` quer dizer
uma linha numa forma que o roteiro não conhece (o `-s` colado a outra letra, o
`--max-time` por extenso): corrija-a à mão em `/root/crontab-depois-do-corte.txt`,
na forma das outras, e rode **só o segundo bloco** de novo — o primeiro refaz o
arquivo a partir do de antes e apaga a correção. `token=` maior que zero é o
token ainda na URL; `Bearer` em zero com `curl` acima de zero é o `cron.token`
vazio (volte ao passo 7 e refaça os dois blocos); o aviso no log ou o domínio
raiz abaixo de `curl` é uma linha corrigida à mão sem o `|| logger -t rotinas
"falhou (curl $?): <a rota>"` no fim, ou com o `www` (escreva-a na forma das
outras); `MAILTO com endereco` acima de zero é um endereço escrito à mão (a 128
(b) tirou o e-mail: apague a linha); e a apuração dos royalties fora de `1 (1
com o aviso dela)` é a linha das 05:25 que falta, que se repete ou que perdeu o
`case` (o **D364**). **Ensaio no repositório:** `sh
scripts/ensaiar-crontab-do-passo-9.sh` tira os dois blocos deste arquivo, roda o
mesmo texto sobre um crontab inventado de 25 linhas com token inventado (uma
delas a da apuração dos royalties, e algumas no `www`), e exige que o caso certo
instale (o token novo nas 25, `token=`, o token velho e o `www` em nenhuma, o
`|| logger` nas 25, o `MAILTO=""` na primeira e a linha dos royalties com o
`case`) e que **não** instalem o token vazio, quatro linhas fora do molde (o
`-sm` colado, o `--silent`, a linha sem `-m`, o `2>/dev/null`), um `token=` fora
da URL, e — corrigidas à mão entre os dois blocos — a linha sem o `logger`, com
outra marca no `logger`, no `www`, com um `%`, o `MAILTO` com endereço e a linha
dos royalties sem o `case`. E roda as linhas instaladas com um `curl` e um
`logger` de mentira: a rotina que dá certo não escreve no log; a que falha
escreve `falhou (curl 22): <a rota>`; a dos royalties escreve a resposta inteira
quando ela traz `aviso`, e nada quando não traz. Com o bloco de antes da
revisão de 06/10/2026, três desses instalavam. Verde no Mac (bash e gawk) e no
contêiner da aplicação (dash e mawk); a primeira versão, ensaiada nos dois, deu
o mesmo crontab byte a byte e o arquivo em `-rw-------`.
**Não provado:** o servidor de produção — que o `logger` existe e o log do
sistema guarda o que ele escreve é o 8 de "O que hoje bloqueia", antes da
janela —, e quem lê o log depois (a 128 (b): *"alguém tem de olhar o log"*). E a
rotina que responde 200 com `sucesso: false` (a trava detida, `Processo já em
execução.`) não vira aviso: o `-f` só vê o código HTTP, e o `case` só lê a
resposta dos royalties.

**O crontab tem de mandar o token certo, ou as rotinas param com 401** (D4,
03/10/2026). É o mesmo `cron.token` que o passo 7 levou ao `local.php`, e é por
isso que o roteiro o lê de lá, e não do crontab de antes. A volta atrás depois
deste passo não existe (ver abaixo); antes dele, o crontab guardado no passo 1
continua intacto.

**Confira depois de 10 minutos** (`npsNotificacoes`, `npsContabilizar` e
`marketing/envios` já rodaram), sem imprimir o token -- a conta mostra só o
código HTTP de cada pedido do crontab feito **depois da marca**:

```bash
while read -r n f; do case $f in /*) tail -n +"$((n + 1))" "$f";; esac; done < /root/nginx-antes-do-cron.txt \
  | grep -E '"GET /api/(cronjobs|marketing|cupons/cuponsValidarVencimento)' \
  | grep -oE '" [0-9]{3} ' | sort | uniq -c
```

| A conta mostra | Quer dizer |
|---|---|
| só `" 200 `, com número **maior que zero** | as rotinas rodam: siga |
| `" 401 ` | o crontab manda um token que não é o `cron.token` do `local.php`: **as rotinas estão paradas** -- e o log do sistema tem uma linha `falhou (curl 22)` por chamada (`journalctl -t rotinas --since '-15 min'`). Sem essas linhas, o 401 pode ser de outro (um robô, sem token) |
| **nada** | a conta não viu o crontab -- não siga achando que está tudo bem: o cron não chamou, o log é outro, ou girou no meio |

**Por que assim, e não um `grep -c '" 401 '` no `access.log`** (que este passo
pedia até a revisão de 03/10/2026, com "tem de dar 0"): o site tem **log
próprio por vhost** (D36 -- a coleta que olhou só `access.log*` achou zero
pedidos a uma rota chamada a cada abertura do aplicativo), e o crontab chama dois
nomes, `www` e o raiz (`docs/f1-6-agendamento.md`). No arquivo errado o
`grep -c` imprime **0** com as 25 rotinas recusadas -- o "tem de dar 0" do
sucesso e o 0 do arquivo errado eram o mesmo número. Por isso a conta lê
**todos** os `*.log` do nginx, exige pedidos **vistos** (o 200 maior que zero),
e só olha o que veio **depois da marca**: os 401 de uma tentativa anterior, já
consertada, não contam. Ensaiado com logs inventados: com o token certo em dois
logs de vhost, `3 " 200 "`; com o errado, `2 " 401 "`; sem pedido nenhum,
nada; e a conta antiga dava 0 no `access.log` com o token errado no log do
vhost. **Refeito em 06/10/2026** com o filtro de hoje, sobre pedidos na forma do
cabeçalho (a URL sem o token): com o token certo em dois logs de vhost,
`3 " 200 "`, e o `/api/cupons/selecionar` de fora; com o errado, `2 " 401 "`;
sem pedido, nada. O filtro de antes, com o `token=`, não via nada no log certo —
e "nada" manda parar.

**O filtro não procura o token na URL desde 06/10/2026:** as linhas vão no
cabeçalho (D331), e o `[^ ]*token=` que estava aqui não acharia pedido nenhum.
As rotas do filtro são só as das rotinas (`/api/cronjobs/*`, as três de
`/api/marketing/` e `cupons/cuponsValidarVencimento`; a `/api/cupons/selecionar`
fica de fora), e a sonda sem token veio antes da marca e fica de fora do mesmo
jeito.

**E acrescentar as linhas de `docs/crontab-a-aplicar.md`** (`crontab -e`, depois
do `crontab` acima). O crontab guardado no passo 1 é o de *antes* do corte, e
`conveniosAtualizarVencimento` foi criada depois — em 28/08/2026, com teste e
com rota, mas sem agendamento, porque a produção está congelada. Refazer o
arquivo a partir dele deixa a rotina sem rodar também depois do corte. A linha
de lá já está na forma nova (o cabeçalho, o `-fsS`, sem o `2>&1`), desde
06/10/2026.

**E as duas limpezas de dentro do MySQL** (o **D26**; as respostas 127 (a), 184
(a) e 195 (a)): as linhas de `pushAppLimpar`, de hora em hora, e de
`agendasExclusoesLimpar`, uma vez por dia, também estão em
`crontab-a-aplicar.md`, já com o `logger`. Na etapa 1 elas entram com o crontab
novo e o evento `limpar_push_app_24h` se desliga na mesma janela
(`docs/procedimento-de-corte-etapa-1.md`, o passo 12): o crontab que o passo 1
desta etapa guardar já as terá.

A linha, o horário e o porquê de cada escolha estão em `crontab-a-aplicar.md`, e
**não são repetidos aqui de propósito** — duas cópias de uma linha de crontab
divergem no primeiro dia em que alguém corrigir só uma. Ela traz avisos que
importam: a faixa de horário a evitar por causa do **D42**, e o domínio a usar,
já que o `curl` das outras 25 linhas está sem `-L`.

Ao aplicar, **mova a linha para a tabela de `f1-6-agendamento.md`** — é de lá
que `scripts/conferir-agendamento.php` tira a verdade sobre o que roda.

**Confira com `php scripts/conferir-agendamento.php`**, que cruza as rotinas com
a tabela de `docs/f1-6-agendamento.md`. Ele deve acusar **uma** pendência
em vez de duas — a que resta é `conveniosAtualizarVencimento` (**D50**), que
espera produção. A outra era `yungasNpsAtualizar`, removida em 31/08 (**D43**).

---

## O ponto de não retorno

**A volta atrás é barata até a primeira escrita real no PostgreSQL** — ou seja,
até o passo 9. Antes disso:

```bash
cp config/autoload/local.mysql.php.guardado config/autoload/local.php
rm -rf data/DoctrineORMModule/Proxy/*
crontab /root/crontab-antes-do-corte.txt
```

Voltar custa o tempo de copiar um arquivo — **mas não devolve o MySQL ao estado
anterior**, e essa frase dizia que devolvia.

**E a vigência do horário volta vazia.** A tabela existe no MySQL por causa do
passo 0, mas ninguém a semeou lá. Antes de reabrir, e **depois** de restaurar o
`local.php` (o primeiro comando acima) — o conferidor lê o banco dele:

```bash
docker compose exec -T mysql mysql -uroot -proot <banco> < scripts/semente-usuarios-horarios.mysql.sql
docker compose exec -T app php scripts/conferir-vigencia-do-horario.php --plataforma=mysql   # tem de sair com 0
```

**A migração só LÊ do MySQL. O passo 3 não.** `pre-migracao-limpeza.sql` escreve
nele, e o que ele escreve sobrevive à volta atrás:

| O quê | Onde |
|---|---|
| Datas anteriores a `1000-01-01` reconstruídas a partir de `data_criacao` | `agendas`, `agendas_fechamentos` |
| Datas anteriores a `1000-01-01` anuladas | `usuarios.data_contratacao`, `clientes.data_nascimento`, `usuarios.data_nascimento` |
| **Linhas duplicadas apagadas** — o molde repetido, ficando o mais antigo, também o de valores diferentes (a sua 237 (c)) | `privilegios` — quem entrar no grupo depois recebe o molde que ficou; quem já está nele não muda |
| O login e o e-mail do **inativo** de cada par que só difere pela caixa, e de cada repetido exato (decisão de 07/10/2026), com `.inativo<id>` no fim | `usuarios.login`, `usuarios.email` — ele não entra de qualquer jeito; ninguém ativo muda |
| Logins e e-mails em minúsculas, se o `UPDATE` do D349 rodou | `usuarios.login`, `usuarios.email` — inofensivo no MySQL, que compara sem caixa |
| As datas de nascimento sem a hora, se o 7 de "O que hoje bloqueia" deu mais que zero | `clientes.data_nascimento`, `unidades_socios.data_nascimento` — o dia fica |
| O dicionário de CEP apagado (decisão de 06/10/2026), e as 27 UFs repostas (D430) | `logradouros`, `bairros`, `cidades` — não volta; `estados` fica com as 27 de `RegraDoCadastroDeCliente::UFS` |
| O grupo 8, Líder, com a visão de gerente (decisão de 06/10/2026) | `grupos.gerente` — o código que volta é o novo, que confere a unidade |
| **As gêmeas de privilégio juntadas** — a mais antiga de cada par (pessoa, funcionalidade) com o OU delas, e as outras apagadas — e **as duas chaves únicas**, no molde e nas pessoas (`migracao-privilegios-unicos.sql`, a sua 125 (a); **D289**, **D374**) | `privilegios_usuarios` — 118 linhas a mais na cópia de 16/08/2026, que só voltam pelo dump de antes da janela; o acesso de cada pessoa é o mesmo. `privilegios` e `privilegios_usuarios` ficam com as `UNIQUE KEY`: o código novo, que é o que volta, funciona com elas; o código antigo, se voltar também, dá erro no "novo" da janela de permissões num par que já tem linha, em vez de gravar a gêmea |
| **Os cupons de sorteio repetidos apagados** -- o segundo do mesmo número da mesma venda no mesmo sorteio -- e **a chave única (sorteio, venda, numero)** (`migracao-sorteios-vendas-unicos.sql`, as suas 124 (a) e 218 (a); **D370**, **D561**) | `sorteios_vendas` -- 4 cupons na cópia de 16/08/2026, que só voltam pelo dump de antes da janela; nenhum resultado muda. A tabela fica com a `UNIQUE KEY`: o código novo, que é o que volta, funciona com ela; o código antigo, se voltar também, dá erro na finalização repetida do PDV antigo, em vez de gravar o segundo cupom |
| **As conclusões repetidas da VIP Class apagadas** -- fica a primeira de cada par (curso, pessoa) -- e **a chave única (vipclass, usuario)** (`migracao-vipclass-conclusao-unica.sql`, a sua 213 (a); **D372**) | `vipclass_checkpoints_usuarios` -- 191 linhas na cópia de 16/08/2026, que só voltam pelo dump de antes da janela; ninguém perde a conclusão. A tabela fica com a `UNIQUE KEY`: o código novo, que é o que volta, funciona com ela; o código antigo, se voltar também, dá erro no envio repetido do checkpoint, em vez de gravar a segunda conclusão |

E o passo 0 fica: as tabelas e colunas novas continuam no MySQL, vazias para o
código antigo.

São **2.416 linhas em 6 colunas** pela última medição na réplica, mais as
duplicatas de `privilegios` e as gêmeas de `privilegios_usuarios` (118 na cópia de
16/08/2026; a réplica diz quantas são na janela). Nada disso é acidente — o script existe para isso, e
sem ele a carga falha no meio. Mas quem volta atrás precisa saber que **volta com
o MySQL já limpo**, e não como estava.

Na prática isso é aceitável, e vale dizer por quê: as datas eram inválidas
(`0000-11-30` e afins), o código já filtra por `IS NOT NULL`, e as duplicatas de
`privilegios` eram exatamente isso — menos o molde repetido de valores diferentes,
se a réplica tiver algum (na cópia de 16/08/2026, nenhum): fica o mais antigo, pela
sua 237 (c), e a lista do bloco 5 diz qual —, e as gêmeas também: a que fica leva o OU do
par, que é o que o `Acl` já somava, e ninguém ganha nem perde acesso. **A volta atrás é segura; ela só não é
integral.** Chamá-la de integral é que era o erro.

**A contagem não se confere na base local:** a anonimização reescreve
`data_nascimento`, então lá esses números aparecem como zero (**D41**). Os 2.416
vêm da réplica de produção.

**Depois do passo 9, não.** Cada venda lançada no PostgreSQL é uma venda que se
perde na volta. Se houver dúvida, ela precisa ser resolvida **antes** de liberar
o acesso, não depois.

Combine o horário limite antes de começar: "se às 5h não estiver liberado,
voltamos" é uma decisão melhor tomada às 22h do que às 5h.

---

## Quanto tempo a janela precisa

**Não sei, e desconfie de quem disser que sabe sem ter medido no seu hardware.**

O que eu medi: **533 segundos** para 83 tabelas e cerca de 16 milhões de linhas,
numa máquina de 24 núcleos, com origem e destino **no mesmo host** — sem rede
entre eles. As seis maiores tabelas concentram o trabalho:

| Tabela | Linhas | MB |
|---|---:|---:|
| `agendas` | 3.779.378 | 269 |
| `vendas_produtos` | 3.256.858 | 263 |
| `vendas` | 2.227.763 | 207 |
| `usuarios_acessos` | 1.878.591 | 69 |
| `pontuacoes_historicos` | 1.182.224 | 115 |
| `notificacoes_clientes` | 873.642 | 33 |

Esse número é um **piso**, e por dois motivos independentes: produção tem 4 vCPU
contra 24, e num corte real origem e destino podem estar em máquinas diferentes,
com a rede no meio.

**Some a esse tempo:** os dois portões de conferência (a quinta prova varre
todas as colunas de data das maiores tabelas), a passada das tags do 7c (não
medida), o teste manual e o que o passo 3 ganhou em 06/10/2026. A suíte não
entra na janela (decisão de 06/10/2026, o passo 8). E some folga para o que der
errado, porque algo vai.

**O passo 3 ganhou um `DELETE` de cerca de 758 mil linhas com chave
estrangeira** (o dicionário de CEP), irreversível e dentro da janela. Medido com
`php scripts/ensaiar-sql-do-passo-3.php --tamanho-de-producao`, que monta as
quatro tabelas do `mysql.sql` com 709.440 + 38.450 + 10.082 + 27 linhas
inventadas e cronometra o roteiro inteiro: **3,7 s** no MySQL 8.4.11 sem limite
de CPU, e **5,2 s** num 8.0.46 limitado a 4 CPUs — os dois com 128 MB de buffer
pool e o log binário ligado (ROW, `sync_binlog = 1`), em 06/10/2026. **Não é o
número da produção**: o disco e o buffer pool de lá não se conhecem (o D189), e a
réplica aplica o mesmo `DELETE` depois. É a ordem de grandeza — segundos, e não
minutos. A medida que vale é a do ensaio geral sobre uma cópia **da produção**,
cuja fase `passo3` roda este roteiro (ver `ensaio-geral-do-corte.md`). Os
`UPDATE` dos logins e do nascimento e a migração do Líder tocam algumas linhas
cada, e não pesam.

O número acima mediu a carga **com** o dicionário de CEP (27/08, antes do
D74). **Desde a decisão de 06/10/2026 o passo 3 o esvazia antes do dump**, e a
produção carrega cerca de 758 mil linhas a menos — o mesmo que uma cópia do
desenvolvimento, que já não o tem (5 logradouros em vez de 709.440, contados na
`franquiabv_dev` em 03/10/2026; o 3 do D120 é de 07/09). Um ensaio sobre uma
cópia **da produção** só mede a janela se rodar o `limpar-dicionario-cep.sql`
nela antes, como o passo 3 fará.

---

## O que pode dar errado, e como cada um se anuncia

| Sintoma | Causa provável | O que fazer |
|---|---|---|
| Carga morre com `invalid input syntax for type date` | `pre-migracao-limpeza.sql` não rodou, ou rodou antes de uma linha nova entrar | rodar e recarregar |
| Carga morre com `missing data for column` | retorno de carro no dado sem o filtro do cano | conferir o `/tmp/escapar-cr.sed` dentro do contêiner com `od` |
| `invalid byte sequence for encoding "UTF8"` | conexão MySQL em latin1 | `--default-character-set=utf8mb4` |
| Aplicação sobe e falha na primeira consulta | papéis criados **antes** do schema | rodar `criar-papeis.sh` de novo |
| Toda consulta devolve erro de `app.inquilino_id` | `DeclararInquilino` não registrado | conferir `doctrine.global.php` |
| Datas 3 horas erradas | fuso do banco em UTC na hora da carga | ajustar o fuso e **recarregar** — ajustar depois não conserta |
| Telas em branco sem erro | conectando como superusuário, RLS ignorado, mas outra coisa | não é RLS: superusuário vê tudo |
| HTTP 500 na primeira página | proxies do Doctrine do banco antigo | `rm -rf data/DoctrineORMModule/Proxy/*` |

---

## Depois

1. **Não derrube o MySQL.** Deixe-o de pé, somente leitura, por algumas semanas.
   Ele é a única fonte para conferir qualquer número que alguém questione.
2. **Acompanhe 48 horas**, com atenção às rotinas de cron: elas rodam uma vez por
   dia e várias só à noite, então o primeiro ciclo completo leva um dia.
3. **`bvip_app` em produção** — se o corte foi feito com a credencial de dono, o
   RLS continua desligado (**D48**).
4. **O tipo do dinheiro (D49) fica para depois.** As 86 colunas numéricas são
   `double` (85 no dump de 16/08; a 86ª é `usuarios_horarios.tempo_atendimento`,
   cópia da de `usuarios`). Trocar para `numeric` é necessário, e **não no mesmo movimento**: o
   portão do corte é "o comportamento financeiro não mudou", e mudar o tipo do
   dinheiro junto torna esse portão impossível de ler.
5. **Os pacotes de venda já cancelada são encerrados uma vez, no corte (D688).** Desde
   03/11/2026 (a fatia 74f; a resposta 285 (a) do dono, o **D686**) cancelar a venda
   encerra o pacote do cliente que ela criou; os pacotes de vendas canceladas antes dela
   continuavam ativos (na cópia, em 03/11/2026: 118 ativos, de 66 vendas e 39 clientes, 166
   sessões restantes nos 69 com saldo positivo -- 5 estão com o saldo negativo, -56
   somados --, nenhum dentro da validade). Pela resposta 287 (a) do dono (a fatia 74g), o
   passo 7 da etapa 1 os encerra no MySQL (`scripts/encerrar-pacotes-de-venda-cancelada.sql`),
   e o 7d daqui no corte de uma vez só (o irmão em `postgres/`), com a contagem antes. Depois
   da janela, mande ao dono a contagem guardada pelo `tee` -- quantos eram, e quantos ainda
   estavam válidos.
6. **As telas antigas que ficam até o corte de cada unidade saem com o ERP antigo dela.**
   Este procedimento é o corte da rede inteira; quando o dono decidiu que uma tela antiga
   fica "até o corte de cada unidade", ela sai aqui, depois do passo 9 e das 48 horas do
   item 2, numa entrega própria do código (com os testes e os portões, no molde da fatia
   76e: os endereços antigos com 404, o item do menu do ERP fora e o item da tela nova
   sem o link para o ERP). A lista, uma linha por tela:
   - **A VIP Class do aluno** (**D715**; a resposta 309 (b) do dono, 06/11/2026): sai o
     `ISCadastro\Controller\VipClassEduController` (o `index`, o `aulas`, o `checkpoint`, o
     `checkpointFinalizar` e o `checkpointCertificado`, `/mod-cadastros/vipClassEdu/...`), a
     fábrica, o invokable e o alias `vipClassEdu`, as três views de `is-cadastro/vip-class-edu/`,
     o item "VIP Class" de primeiro nível do `layout.phtml` e as funções `vipClassEdu*` do
     `mod-configuracoes.js`; no menu da tela nova, o caminho `/iscadastro/vipClassEdu/index`
     entra no `SO_NA_TELA_NOVA`. **Fica** o link público dos materiais
     (`/vipclass/material-download`, o `VipClassMaterialController`): a tela nova o copia
     para compartilhar. A tela nova do aluno (`/adm/api/meus-cursos`) já faz tudo o que a
     antiga faz, pela mesma regra e pelo mesmo serviço da conclusão.
   - **Contas a Pagar** (**D710**; a resposta 306 (b) do dono, 05/11/2026; escrita na fatia 96,
     06/11/2026): sai o `ISFinanceiro\Controller\SaidasController` (o `index`, o
     `identificacao`, o `parcelas`, o `parcelaQuitar`, o `quitarSemPagamento`, o
     `pagamentoEstornar`, o `parcelaEditar`, o `excluirSaida`, o `excluirParcela` e o
     `excluirParcelas`, `/mod-financeiro/saidas/...`), a fábrica, o invokable
     `ISFinanceiro\Controller\Saidas` e o alias `saidas`, a rota `isfinanceiro-admin` (o
     `/mod-financeiro`; hoje o `ISFinanceiro` não tem outro controller do ERP, e no dia se
     confere que nenhum outro chega por ela), as três views de `is-financeiro/saidas/`, o
     `mod-financeiro.js` (e o `appendFile` dele no `layout.phtml`) e o
     `contasAPagarDaTelaAntiga.teste.ts`, que roda esse arquivo; no `layout.phtml`, os itens
     "Nova" e "Gerenciar" do menu "Contas a Pagar"; no menu da tela nova, os caminhos
     `/isfinanceiro/saidas/identificacao` e `/isfinanceiro/saidas/index` entram no
     `SO_NA_TELA_NOVA`. **Fica** tudo o que a tela nova usa e o caixa usa: a
     `/adm/api/saidas` (o `Controller\Adm\SaidasController`), o `SaidaService`, a
     `RegraDaSaida` e a despesa que a sangria do caixa lança. Os testes que despacham a tela
     antiga (o grep por `Controller\SaidasController` e por `mod-financeiro` em `tests/` no dia;
     hoje, entre outros, o `SaidasDoErpTest`, o `D206D207SaidasPelaRotaNoErpTest` e os casos do
     ERP no `ContasAPagarRespostasTest`) saem ou viram o 404 dos endereços antigos, na mesma
     entrega.
     A tela nova (`/adm/api/saidas`) já faz tudo o que a antiga faz, pelo mesmo serviço.
7. **O menu do ERP leva à tela nova só onde o `/novo` é servido** (**D722**; a resposta 314 (c) do
   dono; escrito na fatia 98, 06/11/2026, `docs/f3-telas-restantes/endereco-por-tela.md`). O item
   "VIP Class" de Configurações do `layout.phtml` abre a administração na tela nova
   (`/novo/?tela=...`), e fica ESCONDIDO enquanto o ERP não souber que o `/novo` existe ali -- é o
   padrão, e é o da produção hoje. Quando o `/novo` for publicado no servidor de produção (o mesmo
   endereço do ERP, como no teste do BVIP), e só depois de abri-lo no navegador e ver a tela nova,
   ligue-o uma vez, para a rede inteira:

   ```bash
   # em config/autoload/local.php, no array de cima:   'telaNova' => ['endereco' => '/novo/'],
   # e, se o PHP de producao tiver opcache.validate_timestamps desligado, recarregue o PHP-FPM
   ```

   Para desligar, `'telaNova' => ['endereco' => null],` (ou `'telaNova' => null,`): a chave `telaNova`
   presente decide em qualquer forma, por cima da variável `TELA_NOVA_ENDERECO`, que só o
   `docker-compose.teste.yml` põe; só um endereço que passe na conferência liga. Confira como alguém da rede com o privilégio da VIP Class
   (`vipclass/admin/ler`): o item aparece em Configurações e abre a administração na tela nova; como
   alguém sem ele, o item não aparece. Não depende do PostgreSQL nem da janela: vale para o MySQL
   também.

   **A mesma chave muda o link do e-mail de "Esqueci minha senha"** (a fatia 99, 06/11/2026; a resposta
   319 (b) do dono; a seção 8 de `docs/f3-telas-restantes/tela-entrada.md`): ligada, o link abre a troca
   da senha na tela nova (`<endereço>#redefinir=...`, o token no fragmento, com a origem da `sistema.url` quando o endereço é
   `/novo/`); desligada, a página antiga, como hoje. Confira com a SUA conta: peça o link pela página de
   entrada, abra o e-mail e veja a tela nova pedindo a senha nova.
8. **O convênio das vendas antigas, se o acerto não rodou no MySQL** (**D632**; a resposta 358 (c) do dono; a
   fatia 132, 07/11/2026). Fora da janela e quando o dono quiser, como o da etapa 1 (a seção "Depois da janela"
   de `docs/procedimento-de-corte-etapa-1.md`): o roteiro conta, lista com o convênio de antes e grava em cada
   venda antiga o convênio do último item do convênio, pelo nome do motivo, e avança a "última venda" dos convênios
   que recebem venda concluída mais nova (a 363 (a), a fatia 136; nunca para trás, com a lista da data de antes). Com
   o acerto já feito no MySQL depois da etapa 1, a carga trouxe as vendas acertadas, e este conta 0 a mudar.

   ```bash
   psql -v ON_ERROR_STOP=1 -f postgres/acertar-convenio-das-vendas.sql | tee /root/acertar-convenio-das-vendas-pg-<data>.txt   # D632
   ```

---

## O que este procedimento não cobre

**Os arquivos.** Contratos, certificados, banners e fotos moram no disco local
(**D33**). Se o PostgreSQL for para outra máquina, eles não vão junto — e a
abstração de armazenamento está pronta desde a F1.5, mas o driver S3 não está
ligado.

**A alta disponibilidade.** O failover foi ensaiado em laboratório (F1.7), não no
parque. Cortar para um PostgreSQL de nó único é uma decisão legítima; só precisa
ser consciente.

**A F1.1.** A rota `/autenticacao/migrar-senhas` continua viva em produção e
sobrescreve o hash de todos os usuários ativos numa requisição GET. Isso é
anterior ao corte e não melhora com ele.
